mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
448 lines
19 KiB
Go
448 lines
19 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/public/shared/mlog"
|
|
"github.com/hanzoteam/server/server/public/shared/request"
|
|
"github.com/hanzoteam/server/server/v8/channels/store"
|
|
)
|
|
|
|
// channelJoinRequestPaginationDefaultPerPage matches the public /v1/workspace default
|
|
// for paginated endpoints.
|
|
const channelJoinRequestPaginationDefaultPerPage = 60
|
|
|
|
// channelJoinRequestPaginationMaxPerPage caps a single page's size; mirrors the
|
|
// 200 cap shared by other public list endpoints.
|
|
const channelJoinRequestPaginationMaxPerPage = 200
|
|
|
|
// requestJoinChannelGuard validates that a user is allowed to express interest
|
|
// in joining `channel` and returns a sanitized result for `channel`. Callers
|
|
// are expected to look up `channel` via the store before calling this helper.
|
|
func (a *App) requestJoinChannelGuard(rctx request.CTX, user *model.User, channel *model.Channel) *model.AppError {
|
|
if channel == nil {
|
|
return model.NewAppError("RequestJoinChannel", "app.channel.get.existing.app_error", nil, "", http.StatusNotFound)
|
|
}
|
|
|
|
if channel.DeleteAt != 0 {
|
|
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.archived.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
if channel.Type != model.ChannelTypePrivate {
|
|
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.not_private.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
if !channel.Discoverable {
|
|
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.not_discoverable.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
|
|
}
|
|
|
|
// Shared channels join through their own remote-cluster sync mechanism.
|
|
if channel.IsShared() {
|
|
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.shared.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
|
|
}
|
|
|
|
if user.IsGuest() {
|
|
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.guest.app_error", nil, "user_id="+user.Id, http.StatusForbidden)
|
|
}
|
|
|
|
if user.DeleteAt != 0 {
|
|
return model.NewAppError("RequestJoinChannel", "app.channel.add_member.deleted_user.app_error", nil, "", http.StatusForbidden)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RequestJoinChannel decides between an immediate ABAC-gated auto-join and an
|
|
// asynchronous request-to-join row.
|
|
//
|
|
// Returns the persisted ChannelJoinRequest when the user must wait for an
|
|
// admin review, or nil when the user was added directly to the channel (the
|
|
// caller can detect this via the `joined` return value).
|
|
func (a *App) RequestJoinChannel(rctx request.CTX, userID, channelID, message string) (joined bool, req *model.ChannelJoinRequest, appErr *model.AppError) {
|
|
user, appErr := a.GetUser(userID)
|
|
if appErr != nil {
|
|
return false, nil, appErr
|
|
}
|
|
|
|
channel, appErr := a.GetChannel(rctx, channelID)
|
|
if appErr != nil {
|
|
return false, nil, appErr
|
|
}
|
|
|
|
if guardErr := a.requestJoinChannelGuard(rctx, user, channel); guardErr != nil {
|
|
return false, nil, guardErr
|
|
}
|
|
|
|
_, memberErr := a.Srv().Store().Channel().GetMember(rctx, channel.Id, user.Id)
|
|
if memberErr == nil {
|
|
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.already_member.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
|
|
}
|
|
var nfErr *store.ErrNotFound
|
|
if !errors.As(memberErr, &nfErr) {
|
|
return false, nil, model.NewAppError("RequestJoinChannel", "app.channel.get_member.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr)
|
|
}
|
|
|
|
enforced, appErr := a.ChannelAccessControlled(rctx, channel.Id)
|
|
if appErr != nil {
|
|
return false, nil, appErr
|
|
}
|
|
|
|
// ABAC gate: when an active policy is attached and the user qualifies, add
|
|
// the member directly. AddChannelMember re-runs the PDP gate inside
|
|
// addUserToChannel, so a denial here is authoritative; a non-allow result
|
|
// falls through to the request-row path below ONLY when there is no policy.
|
|
if enforced {
|
|
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
|
|
if evalErr != nil {
|
|
return false, nil, evalErr
|
|
}
|
|
if !decision {
|
|
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.policy_denied.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
|
|
}
|
|
|
|
if _, err := a.AddChannelMember(rctx, user.Id, channel, ChannelMemberOpts{UserRequestorID: user.Id}); err != nil {
|
|
return false, nil, err
|
|
}
|
|
return true, nil, nil
|
|
}
|
|
|
|
pending := &model.ChannelJoinRequest{
|
|
ChannelId: channel.Id,
|
|
UserId: user.Id,
|
|
Message: message,
|
|
}
|
|
|
|
saved, err := a.Srv().Store().ChannelJoinRequest().Save(pending)
|
|
if err != nil {
|
|
var conflict *store.ErrConflict
|
|
if errors.As(err, &conflict) {
|
|
existing, getErr := a.Srv().Store().ChannelJoinRequest().GetPendingForChannelAndUser(channel.Id, user.Id)
|
|
if getErr == nil {
|
|
return false, existing, nil
|
|
}
|
|
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.duplicate.app_error", nil, "channel_id="+channel.Id, http.StatusConflict)
|
|
}
|
|
if appErr, ok := err.(*model.AppError); ok {
|
|
return false, nil, appErr
|
|
}
|
|
return false, nil, model.NewAppError("RequestJoinChannel", "app.channel.join_request.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
a.broadcastChannelJoinRequestCreated(rctx, channel, saved)
|
|
return false, saved, nil
|
|
}
|
|
|
|
// WithdrawChannelJoinRequest flips a pending request the calling user owns to
|
|
// the withdrawn state. Non-owners receive a 404 (no oracle on existence) and
|
|
// already-terminal rows return 409.
|
|
func (a *App) WithdrawChannelJoinRequest(rctx request.CTX, requestID, userID string) (*model.ChannelJoinRequest, *model.AppError) {
|
|
current, err := a.Srv().Store().ChannelJoinRequest().Get(requestID)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
if errors.As(err, &nfErr) {
|
|
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
|
|
}
|
|
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
if current.UserId != userID {
|
|
// Hide the row from non-owners by returning the same not-found
|
|
// response. The reviewer flow uses different endpoints.
|
|
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
|
|
}
|
|
|
|
if current.Status != model.ChannelJoinRequestStatusPending {
|
|
return nil, model.NewAppError("WithdrawChannelJoinRequest", "api.channel.discoverable_join_request.not_pending.app_error", nil, "request_id="+requestID, http.StatusConflict)
|
|
}
|
|
|
|
current.Status = model.ChannelJoinRequestStatusWithdrawn
|
|
current.Message = ""
|
|
|
|
updated, err := a.Srv().Store().ChannelJoinRequest().Update(current)
|
|
if err != nil {
|
|
if appErr, ok := err.(*model.AppError); ok {
|
|
return nil, appErr
|
|
}
|
|
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
channel, channelErr := a.GetChannel(rctx, updated.ChannelId)
|
|
if channelErr != nil {
|
|
// Channel went away mid-flight — still report the update; we just
|
|
// can't broadcast to the admin queue.
|
|
rctx.Logger().Warn("WithdrawChannelJoinRequest: failed to load channel for broadcast", mlog.String("channel_id", updated.ChannelId), mlog.Err(channelErr))
|
|
return updated, nil
|
|
}
|
|
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
|
|
return updated, nil
|
|
}
|
|
|
|
// GetMyChannelJoinRequest returns the calling user's active pending request for
|
|
// `channelID`, or nil if none exists. It never returns an error for a missing
|
|
// row — that's the non-pending state and is expected.
|
|
func (a *App) GetMyChannelJoinRequest(rctx request.CTX, userID, channelID string) (*model.ChannelJoinRequest, *model.AppError) {
|
|
req, err := a.Srv().Store().ChannelJoinRequest().GetPendingForChannelAndUser(channelID, userID)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
if errors.As(err, &nfErr) {
|
|
return nil, nil
|
|
}
|
|
return nil, model.NewAppError("GetMyChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// GetMyChannelJoinRequests lists the calling user's join requests across all
|
|
// channels. The "My Pending Requests" tab filters by `Status="pending"` (the
|
|
// default when opts.Status is empty).
|
|
func (a *App) GetMyChannelJoinRequests(rctx request.CTX, userID string, opts model.GetChannelJoinRequestsOpts) (*model.ChannelJoinRequestList, *model.AppError) {
|
|
opts = sanitizeJoinRequestListOpts(opts)
|
|
rows, total, err := a.Srv().Store().ChannelJoinRequest().GetForUser(userID, opts)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetMyChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return &model.ChannelJoinRequestList{Requests: rows, TotalCount: total}, nil
|
|
}
|
|
|
|
// GetChannelJoinRequests lists the join requests targeting `channelID` for the
|
|
// admin queue UI. The visibility check is performed by the API layer via the
|
|
// PermissionManageChannelJoinRequests permission.
|
|
func (a *App) GetChannelJoinRequests(rctx request.CTX, channelID string, opts model.GetChannelJoinRequestsOpts) (*model.ChannelJoinRequestList, *model.AppError) {
|
|
opts = sanitizeJoinRequestListOpts(opts)
|
|
rows, total, err := a.Srv().Store().ChannelJoinRequest().GetForChannel(channelID, opts)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return &model.ChannelJoinRequestList{Requests: rows, TotalCount: total}, nil
|
|
}
|
|
|
|
// CountPendingChannelJoinRequests returns the number of pending join requests
|
|
// for `channelID`, used by the channel-header badge.
|
|
func (a *App) CountPendingChannelJoinRequests(rctx request.CTX, channelID string) (int64, *model.AppError) {
|
|
count, err := a.Srv().Store().ChannelJoinRequest().CountPending(channelID)
|
|
if err != nil {
|
|
return 0, model.NewAppError("CountPendingChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// UpdateChannelJoinRequest applies an admin review (approve / deny) to a
|
|
// pending request. When approving, the user is added via AddChannelMember so
|
|
// the existing PDP gate inside addUserToChannel re-runs — admins cannot bypass
|
|
// an active ABAC policy. The store row is only updated after a successful add
|
|
// to keep the audit trail consistent.
|
|
func (a *App) UpdateChannelJoinRequest(rctx request.CTX, requestID, channelID string, patch *model.ChannelJoinRequestPatch, reviewerID string) (*model.ChannelJoinRequest, *model.AppError) {
|
|
if patch == nil {
|
|
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.invalid_patch.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
switch patch.Status {
|
|
case model.ChannelJoinRequestStatusApproved, model.ChannelJoinRequestStatusDenied:
|
|
default:
|
|
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.invalid_patch.app_error", nil, "status="+patch.Status, http.StatusBadRequest)
|
|
}
|
|
|
|
current, err := a.Srv().Store().ChannelJoinRequest().Get(requestID)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
if errors.As(err, &nfErr) {
|
|
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
|
|
}
|
|
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
// Defense in depth: refuse cross-channel updates so a forged request id
|
|
// can't be reviewed against a channel the admin happens to own.
|
|
if current.ChannelId != channelID {
|
|
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
|
|
}
|
|
|
|
if current.Status != model.ChannelJoinRequestStatusPending {
|
|
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.not_pending.app_error", nil, "request_id="+requestID, http.StatusConflict)
|
|
}
|
|
|
|
channel, appErr := a.GetChannel(rctx, current.ChannelId)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
if patch.Status == model.ChannelJoinRequestStatusApproved {
|
|
if _, err := a.AddChannelMember(rctx, current.UserId, channel, ChannelMemberOpts{UserRequestorID: reviewerID}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
current.Status = patch.Status
|
|
current.ReviewedBy = reviewerID
|
|
current.ReviewedAt = model.GetMillis()
|
|
current.DenialReason = ""
|
|
if patch.Status == model.ChannelJoinRequestStatusDenied && patch.DenialReason != nil {
|
|
current.DenialReason = *patch.DenialReason
|
|
}
|
|
// Drop the original message from the response; it served its purpose
|
|
// during review and keeping it would leak free-text into the audit trail.
|
|
current.Message = ""
|
|
|
|
updated, err := a.Srv().Store().ChannelJoinRequest().Update(current)
|
|
if err != nil {
|
|
if appErr, ok := err.(*model.AppError); ok {
|
|
return nil, appErr
|
|
}
|
|
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
|
|
return updated, nil
|
|
}
|
|
|
|
// sanitizeJoinRequestListOpts clamps user-provided pagination + status options
|
|
// so the store sees a normalized request.
|
|
func sanitizeJoinRequestListOpts(opts model.GetChannelJoinRequestsOpts) model.GetChannelJoinRequestsOpts {
|
|
if opts.Status == "" {
|
|
opts.Status = model.ChannelJoinRequestStatusPending
|
|
} else if !model.IsValidChannelJoinRequestStatus(opts.Status) {
|
|
opts.Status = model.ChannelJoinRequestStatusPending
|
|
}
|
|
if opts.Page < 0 {
|
|
opts.Page = 0
|
|
}
|
|
if opts.PerPage <= 0 {
|
|
opts.PerPage = channelJoinRequestPaginationDefaultPerPage
|
|
} else if opts.PerPage > channelJoinRequestPaginationMaxPerPage {
|
|
opts.PerPage = channelJoinRequestPaginationMaxPerPage
|
|
}
|
|
return opts
|
|
}
|
|
|
|
// evaluateChannelMembership runs the access-control PDP for `user` against the
|
|
// `membership` action on `channel`, returning the boolean decision. Errors
|
|
// from the PDP are returned to callers so they can choose between the
|
|
// "channel is invisible" (visibility filter) or "channel cannot be joined"
|
|
// (request flow) fail-secure semantics. Callers must have already verified
|
|
// that `channel.PolicyEnforced` is true before invoking the PDP.
|
|
func (a *App) evaluateChannelMembership(rctx request.CTX, user *model.User, channel *model.Channel) (bool, *model.AppError) {
|
|
acs := a.Srv().Channels().AccessControl
|
|
if acs == nil {
|
|
// No ABAC service → fail-secure. The channel acts as if the user did
|
|
// not satisfy the policy.
|
|
return false, nil
|
|
}
|
|
|
|
subject, appErr := a.BuildAccessControlSubject(rctx, user.Id, user.Roles, channel.Id)
|
|
if appErr != nil {
|
|
return false, appErr
|
|
}
|
|
|
|
decision, evalErr := acs.AccessEvaluation(rctx, model.AccessRequest{
|
|
Subject: *subject,
|
|
Resource: model.Resource{
|
|
Type: model.AccessControlPolicyTypeChannel,
|
|
ID: channel.Id,
|
|
},
|
|
Action: "membership",
|
|
})
|
|
if evalErr != nil {
|
|
return false, evalErr
|
|
}
|
|
return decision.Decision, nil
|
|
}
|
|
|
|
// channelAdminUserIDs returns the user ids of channel members with the
|
|
// scheme-admin role on `channelID`. Used to scope WS broadcasts of join-request
|
|
// events to the queue audience. Failures bubble up because broadcasting to no
|
|
// one would silently break the admin UI.
|
|
func (a *App) channelAdminUserIDs(rctx request.CTX, channelID string) ([]string, *model.AppError) {
|
|
const channelMembersPageSize = 200
|
|
|
|
admins := []string{}
|
|
page := 0
|
|
for {
|
|
members, err := a.Srv().Store().Channel().GetMembers(model.ChannelMembersGetOptions{
|
|
ChannelID: channelID,
|
|
Offset: page * channelMembersPageSize,
|
|
Limit: channelMembersPageSize,
|
|
})
|
|
if err != nil {
|
|
return nil, model.NewAppError("channelAdminUserIDs", "app.channel.get_members.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
for _, m := range members {
|
|
if m.SchemeAdmin {
|
|
admins = append(admins, m.UserId)
|
|
}
|
|
}
|
|
if len(members) < channelMembersPageSize {
|
|
break
|
|
}
|
|
page++
|
|
}
|
|
return admins, nil
|
|
}
|
|
|
|
// broadcastChannelJoinRequestCreated fires a channel_join_request_created event
|
|
// scoped to the channel admin set, using the OnlyChannelAdmins broadcast hook
|
|
// to filter out non-admin members the channel-id broadcast would otherwise
|
|
// reach.
|
|
func (a *App) broadcastChannelJoinRequestCreated(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest) {
|
|
a.publishChannelJoinRequestEvent(rctx, channel, req, model.WebsocketEventChannelJoinRequestCreated, true /* adminsOnly */)
|
|
}
|
|
|
|
// broadcastChannelJoinRequestUpdated fires a channel_join_request_updated event
|
|
// to the channel admin set + the requesting user (so their My Pending Requests
|
|
// list reacts in real-time).
|
|
func (a *App) broadcastChannelJoinRequestUpdated(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest) {
|
|
// Send a dedicated copy to the requester so an offline-but-then-reconnected
|
|
// requester gets their own row update even when they are not a channel
|
|
// member yet (the channel-id broadcast wouldn't reach them otherwise).
|
|
if req.UserId != "" {
|
|
userMessage := model.NewWebSocketEvent(model.WebsocketEventChannelJoinRequestUpdated, "", "", req.UserId, nil, "")
|
|
userMessage.Add("request", marshalChannelJoinRequest(rctx, req))
|
|
userMessage.Add("channel_id", channel.Id)
|
|
a.Publish(userMessage)
|
|
}
|
|
a.publishChannelJoinRequestEvent(rctx, channel, req, model.WebsocketEventChannelJoinRequestUpdated, true /* adminsOnly */)
|
|
}
|
|
|
|
func (a *App) publishChannelJoinRequestEvent(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest, event model.WebsocketEventType, adminsOnly bool) {
|
|
message := model.NewWebSocketEvent(event, "", channel.Id, "", nil, "")
|
|
message.Add("request", marshalChannelJoinRequest(rctx, req))
|
|
message.Add("channel_id", channel.Id)
|
|
|
|
if adminsOnly {
|
|
admins, appErr := a.channelAdminUserIDs(rctx, channel.Id)
|
|
if appErr != nil {
|
|
rctx.Logger().Warn("Failed to compute channel admin set for join request broadcast",
|
|
mlog.String("channel_id", channel.Id),
|
|
mlog.Err(appErr),
|
|
)
|
|
return
|
|
}
|
|
useOnlyChannelAdminsHook(message, admins)
|
|
}
|
|
a.Publish(message)
|
|
}
|
|
|
|
// marshalChannelJoinRequest returns the request as a JSON string for the WS
|
|
// payload. JSON encoding errors are logged and the payload is delivered as an
|
|
// empty string so the event still arrives (clients can tolerate a missing
|
|
// request body and refetch).
|
|
func marshalChannelJoinRequest(rctx request.CTX, req *model.ChannelJoinRequest) string {
|
|
if req == nil {
|
|
return ""
|
|
}
|
|
buf, err := json.Marshal(req)
|
|
if err != nil {
|
|
rctx.Logger().Warn("Failed to marshal ChannelJoinRequest for WS broadcast",
|
|
mlog.String("request_id", req.Id),
|
|
mlog.Err(err),
|
|
)
|
|
return ""
|
|
}
|
|
return string(buf)
|
|
}
|