Files
team/server/channels/app/channel_join_request.go
zeekay 8feb24ea1f One name for this fork, and it is ours
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.
2026-08-28 23:35:36 -07:00

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