snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
566
server/channels/app/guarded_hooks.go
Normal file
566
server/channels/app/guarded_hooks.go
Normal file
@@ -0,0 +1,566 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Channel-guard dispatch helpers.
|
||||
//
|
||||
// Each runGuarded<Hook> helper implements two-phase plugin dispatch: Phase A fans out to non-guard
|
||||
// plugins via RunMultiHookExcluding (fail-open, preserving RunMultiHook semantics — when guards is
|
||||
// empty the exclude list is empty and the iteration is identical to plain RunMultiHook); Phase B
|
||||
// calls each guard claimant in PluginId-sorted order via the *WithRPCErr companion, and fail-closed
|
||||
// on transport errors. Phase B's for-range is a no-op when there are no guards, so unguarded
|
||||
// channels traverse the same single linear flow with zero extra work beyond the Phase A dispatch.
|
||||
//
|
||||
// Allow-by-default for non-implementing claimants: a plugin may register a channel guard without
|
||||
// implementing every guarded hook. When Phase B reaches such a claimant, the *WithRPCErr
|
||||
// companion's g.implemented[<HookID>] gate skips the RPC call entirely and returns zero values with
|
||||
// a nil error. The helper's three guard branches all skip in that case, so the claimant contributes
|
||||
// nothing, basically: "this plugin had no opinion on this hook." Iteration continues to the next
|
||||
// claimant.
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
// resolveGuards returns the (sorted-by-PluginId) guard slice for channelID along with a
|
||||
// non-nil rejectErr when the request must fail-close (plugin system disabled, or a specific
|
||||
// claimant is inactive). The helper picks the right operator-facing log message internally.
|
||||
// (nil, nil) means the channel is unguarded — Phase A still runs (with no exclusions) and
|
||||
// Phase B's loop becomes a no-op. (guards, nil) means proceed with two-phase dispatch.
|
||||
func (a *App) resolveGuards(rctx request.CTX, channelID, callerName string) (guards []*store.ChannelGuard, rejectErr *model.AppError) {
|
||||
ch := a.Channels()
|
||||
raw := ch.getGuardsForChannel(channelID)
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sorted := append([]*store.ChannelGuard(nil), raw...)
|
||||
slices.SortFunc(sorted, func(a, b *store.ChannelGuard) int { return cmp.Compare(a.PluginId, b.PluginId) })
|
||||
env := ch.GetPluginsEnvironment()
|
||||
if env == nil {
|
||||
// Plugin system disabled in config or not yet initialized, but guards exist for this
|
||||
// channel. Operator action: flip PluginSettings.Enable on, or remove the guards.
|
||||
return sorted, logAndErrPluginsDisabled(rctx, channelID, callerName)
|
||||
}
|
||||
var inactive []string
|
||||
for _, g := range sorted {
|
||||
if !env.IsActive(g.PluginId) {
|
||||
inactive = append(inactive, g.PluginId)
|
||||
}
|
||||
}
|
||||
if len(inactive) > 0 {
|
||||
return sorted, logAndErrPluginInactive(rctx, channelID, inactive, callerName)
|
||||
}
|
||||
return sorted, nil
|
||||
}
|
||||
|
||||
// logAndErrPluginInactive emits an operator-facing Error log identifying the specific guard
|
||||
// plugins that are currently inactive, then returns a generic 503 AppError. A guard plugin
|
||||
// being down is an operational failure: the request must be rejected, but internal plugin IDs
|
||||
// do not belong in the user-facing response. Operators read the log to diagnose which plugin
|
||||
// to recover.
|
||||
func logAndErrPluginInactive(rctx request.CTX, channelID string, pluginIDs []string, callerName string) *model.AppError {
|
||||
rctx.Logger().Error("Channel guard rejected operation: claiming plugin is not active",
|
||||
mlog.String("error_id", "guard_plugin_inactive"),
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.Array("plugin_ids", pluginIDs),
|
||||
mlog.String("caller", callerName),
|
||||
)
|
||||
return model.NewAppError(callerName, "app.plugin.inactive_guard.app_error", nil, "", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
// logAndErrPluginsDisabled emits an operator-facing Error log when the plugin system is off
|
||||
// (PluginSettings.Enable == false or not yet initialized) but guards are still cached for the
|
||||
// channel. Distinct from logAndErrPluginInactive: the cause is the global plugin switch, not
|
||||
// a specific plugin failure. Returns the same generic 503 to the user.
|
||||
func logAndErrPluginsDisabled(rctx request.CTX, channelID, callerName string) *model.AppError {
|
||||
rctx.Logger().Error("Channel guard rejected operation: plugin system is disabled but guards exist for this channel",
|
||||
mlog.String("error_id", "plugins_disabled_with_guards"),
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.String("caller", callerName),
|
||||
)
|
||||
return model.NewAppError(callerName, "app.plugin.inactive_guard.app_error", nil, "", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func appErrHookFailed(pluginID, callerName string, err error) *model.AppError {
|
||||
appErr := model.NewAppError(callerName, "app.plugin.guard_hook_failed.app_error",
|
||||
map[string]any{"PluginID": pluginID}, "", http.StatusServiceUnavailable)
|
||||
if err != nil {
|
||||
return appErr.Wrap(err)
|
||||
}
|
||||
return appErr
|
||||
}
|
||||
|
||||
func pluginIDsOf(guards []*store.ChannelGuard) []string {
|
||||
ids := make([]string, len(guards))
|
||||
for i, g := range guards {
|
||||
ids[i] = g.PluginId
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// runGuardedMessageWillBePosted dispatches MessageWillBePosted. Returns the (possibly
|
||||
// replaced) post, the IDs of plugins that received the post content (for delivery tracking),
|
||||
// or an AppError on rejection or RPC failure. The returned IDs are non-nil only on success;
|
||||
// the caller records delivery after the post is saved, since the post has no ID at hook time.
|
||||
func (a *App) runGuardedMessageWillBePosted(rctx request.CTX, post *model.Post) (*model.Post, []string, *model.AppError) {
|
||||
guards, rejectErr := a.resolveGuards(rctx, post.ChannelId, "createPost")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, nil, rejectErr
|
||||
}
|
||||
|
||||
var metadata *model.PostMetadata
|
||||
if post.Metadata != nil {
|
||||
metadata = post.Metadata.Copy()
|
||||
}
|
||||
|
||||
trackPluginDelivery := a.deliveryTrackingEnabled()
|
||||
var deliveredPluginIDs []string
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionError *model.AppError
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, manifest *model.Manifest) bool {
|
||||
replacementPost, rejectionReason := hooks.MessageWillBePosted(pCtx, post.ForPlugin())
|
||||
if trackPluginDelivery && manifest != nil {
|
||||
deliveredPluginIDs = append(deliveredPluginIDs, manifest.Id)
|
||||
}
|
||||
|
||||
if rejectionReason != "" {
|
||||
id := "Post rejected by plugin. " + rejectionReason
|
||||
if rejectionReason == plugin.DismissPostError {
|
||||
id = plugin.DismissPostError
|
||||
}
|
||||
rejectionError = model.NewAppError("createPost", id, nil, "", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if replacementPost != nil {
|
||||
post = replacementPost
|
||||
if post.Metadata != nil && metadata != nil {
|
||||
post.Metadata.Priority = metadata.Priority
|
||||
} else {
|
||||
post.Metadata = metadata
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, plugin.MessageWillBePostedID)
|
||||
if rejectionError != nil {
|
||||
return nil, nil, rejectionError
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, nil, logAndErrPluginInactive(rctx, post.ChannelId, []string{g.PluginId}, "CreatePost")
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.MessageWillBePostedWithRPCErr(pCtx, post.ForPlugin())
|
||||
if rpcErr != nil {
|
||||
return nil, nil, appErrHookFailed(g.PluginId, "CreatePost", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
id := "Post rejected by plugin. " + reason
|
||||
if reason == plugin.DismissPostError {
|
||||
id = plugin.DismissPostError
|
||||
}
|
||||
return nil, nil, model.NewAppError("createPost", id, nil, "", http.StatusBadRequest)
|
||||
}
|
||||
if replacement != nil {
|
||||
post = replacement
|
||||
if post.Metadata != nil && metadata != nil {
|
||||
post.Metadata.Priority = metadata.Priority
|
||||
} else {
|
||||
post.Metadata = metadata
|
||||
}
|
||||
}
|
||||
if trackPluginDelivery {
|
||||
deliveredPluginIDs = append(deliveredPluginIDs, g.PluginId)
|
||||
}
|
||||
}
|
||||
|
||||
return post, deliveredPluginIDs, nil
|
||||
}
|
||||
|
||||
// runGuardedMessageWillBeUpdated dispatches MessageWillBeUpdated. In the non-guarded
|
||||
// hook variant, either newPost == nil OR rejectionReason != "" signals rejection.
|
||||
func (a *App) runGuardedMessageWillBeUpdated(rctx request.CTX, newPost, oldPost *model.Post) (*model.Post, *model.AppError) {
|
||||
guards, rejectErr := a.resolveGuards(rctx, oldPost.ChannelId, "UpdatePost")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, rejectErr
|
||||
}
|
||||
|
||||
// buildUpdateRejectionErr mirrors the legacy error shape at post.go UpdatePost.
|
||||
buildUpdateRejectionErr := func(reason string) *model.AppError {
|
||||
id := "Post rejected by plugin. " + reason
|
||||
if reason == plugin.DismissPostError {
|
||||
id = plugin.DismissPostError
|
||||
}
|
||||
return model.NewAppError("UpdatePost", id, nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
trackPluginDelivery := a.deliveryTrackingEnabled()
|
||||
var deliveredPluginIDs []string
|
||||
|
||||
// Deferred so a later rejection cannot discard plugins that already received the content.
|
||||
// oldPost carries the same id and channel as newPost, and is non-nil on every exit.
|
||||
defer func() {
|
||||
a.RecordPostDeliveryToPlugins(rctx, deliveredPluginIDs, oldPost)
|
||||
}()
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionReason string
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, manifest *model.Manifest) bool {
|
||||
newPost, rejectionReason = hooks.MessageWillBeUpdated(pCtx, newPost.ForPlugin(), oldPost.ForPlugin())
|
||||
if newPost != nil && trackPluginDelivery && manifest != nil {
|
||||
deliveredPluginIDs = append(deliveredPluginIDs, manifest.Id)
|
||||
}
|
||||
return newPost != nil
|
||||
}, plugin.MessageWillBeUpdatedID)
|
||||
if newPost == nil {
|
||||
return nil, buildUpdateRejectionErr(rejectionReason)
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, logAndErrPluginInactive(rctx, oldPost.ChannelId, []string{g.PluginId}, "UpdatePost")
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.MessageWillBeUpdatedWithRPCErr(pCtx, newPost.ForPlugin(), oldPost.ForPlugin())
|
||||
if rpcErr != nil {
|
||||
return nil, appErrHookFailed(g.PluginId, "UpdatePost", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return nil, buildUpdateRejectionErr(reason)
|
||||
}
|
||||
// If replacement == nil && reason == "" && rpcErr == nil, the claimant had no opinion
|
||||
// (did not implement the hook). Do not treat as rejection — continue iterating.
|
||||
if replacement != nil {
|
||||
newPost = replacement
|
||||
}
|
||||
// Only record delivery for claimants that actually implemented the hook and received
|
||||
// the post content. A (nil, "", nil) result means nothing was delivered to them.
|
||||
if trackPluginDelivery && replacement != nil {
|
||||
deliveredPluginIDs = append(deliveredPluginIDs, g.PluginId)
|
||||
}
|
||||
}
|
||||
|
||||
return newPost, nil
|
||||
}
|
||||
|
||||
// runGuardedChannelMemberWillBeAdded dispatches ChannelMemberWillBeAdded. Returns the (possibly
|
||||
// replaced) member, or an AppError on rejection or RPC failure.
|
||||
func (a *App) runGuardedChannelMemberWillBeAdded(rctx request.CTX, channelID string, member *model.ChannelMember) (*model.ChannelMember, *model.AppError) {
|
||||
guards, rejectErr := a.resolveGuards(rctx, channelID, "AddUserToChannel")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, rejectErr
|
||||
}
|
||||
|
||||
buildMemberRejectionErr := func(reason string) *model.AppError {
|
||||
return model.NewAppError("AddUserToChannel", "app.channel.add_user.to.channel.rejected_by_plugin",
|
||||
map[string]any{"Reason": reason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionError *model.AppError
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
updatedMember, reason := hooks.ChannelMemberWillBeAdded(pCtx, member)
|
||||
if reason != "" {
|
||||
rejectionError = buildMemberRejectionErr(reason)
|
||||
return false
|
||||
}
|
||||
if updatedMember != nil {
|
||||
member = updatedMember
|
||||
}
|
||||
return true
|
||||
}, plugin.ChannelMemberWillBeAddedID)
|
||||
if rejectionError != nil {
|
||||
return nil, rejectionError
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, logAndErrPluginInactive(rctx, channelID, []string{g.PluginId}, "addUserToChannel")
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.ChannelMemberWillBeAddedWithRPCErr(pCtx, member)
|
||||
if rpcErr != nil {
|
||||
return nil, appErrHookFailed(g.PluginId, "addUserToChannel", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return nil, buildMemberRejectionErr(reason)
|
||||
}
|
||||
// If replacement == nil && reason == "" && rpcErr == nil, the claimant had no opinion
|
||||
// (did not implement the hook). Do not treat as rejection — continue iterating.
|
||||
if replacement != nil {
|
||||
member = replacement
|
||||
}
|
||||
}
|
||||
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// runGuardedChannelWillBeUpdated dispatches ChannelWillBeUpdated. Guard plugins may not mutate
|
||||
// Channel.Type — type changes must go through dedicated paths (e.g., UpdateChannelPrivacy). The
|
||||
// check applies only to guarded channels; unguarded callers retain RunMultiHook's permissive behavior.
|
||||
func (a *App) runGuardedChannelWillBeUpdated(rctx request.CTX, newChannel, oldChannel *model.Channel) (*model.Channel, *model.AppError) {
|
||||
guards, rejectErr := a.resolveGuards(rctx, newChannel.Id, "UpdateChannel")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, rejectErr
|
||||
}
|
||||
|
||||
buildUpdateRejectionErr := func(reason string) *model.AppError {
|
||||
return model.NewAppError("UpdateChannel", "app.channel.update_channel.rejected_by_plugin",
|
||||
map[string]any{"Reason": reason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
buildTypeMutationErr := func(offendingPluginID string) *model.AppError {
|
||||
return model.NewAppError("UpdateChannel", "app.channel.update_channel.plugin_type_mutation.app_error",
|
||||
map[string]any{"PluginID": offendingPluginID}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
// Track the last replacing plugin ID for type-mutation attribution (used only when guarded).
|
||||
var rejectionReason string
|
||||
var lastReplacingPluginID string
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, manifest *model.Manifest) bool {
|
||||
replacement, reason := hooks.ChannelWillBeUpdated(pCtx, newChannel, oldChannel)
|
||||
if reason != "" {
|
||||
rejectionReason = reason
|
||||
return false
|
||||
}
|
||||
if replacement != nil {
|
||||
newChannel = replacement
|
||||
lastReplacingPluginID = manifest.Id
|
||||
}
|
||||
return true
|
||||
}, plugin.ChannelWillBeUpdatedID)
|
||||
if rejectionReason != "" {
|
||||
return nil, buildUpdateRejectionErr(rejectionReason)
|
||||
}
|
||||
// Type-mutation check applies only to guarded channels; unguarded callers retain
|
||||
// RunMultiHook's permissive semantics.
|
||||
if len(guards) > 0 && lastReplacingPluginID != "" && newChannel.Type != oldChannel.Type {
|
||||
return nil, buildTypeMutationErr(lastReplacingPluginID)
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, logAndErrPluginInactive(rctx, newChannel.Id, []string{g.PluginId}, "UpdateChannel")
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.ChannelWillBeUpdatedWithRPCErr(pCtx, newChannel, oldChannel)
|
||||
if rpcErr != nil {
|
||||
return nil, appErrHookFailed(g.PluginId, "UpdateChannel", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return nil, buildUpdateRejectionErr(reason)
|
||||
}
|
||||
// If replacement == nil && reason == "" && rpcErr == nil, the claimant had no opinion
|
||||
// (did not implement the hook). Do not treat as rejection — continue iterating.
|
||||
if replacement != nil {
|
||||
newChannel = replacement
|
||||
// Check immediately after each Phase B replacement.
|
||||
if newChannel.Type != oldChannel.Type {
|
||||
return nil, buildTypeMutationErr(g.PluginId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newChannel, nil
|
||||
}
|
||||
|
||||
// runGuardedScheduledPostWillBeCreated dispatches ScheduledPostWillBeCreated. Both SaveScheduledPost
|
||||
// and UpdateScheduledPost share the same hook; the callerName and buildRejectionErr params let them
|
||||
// surface distinct rejection error IDs while reusing the same two-phase body. buildRejectionErr is
|
||||
// supplied by the caller so the rejection error ID stays a string literal in a NewAppError call the
|
||||
// i18n extractor can see.
|
||||
func (a *App) runGuardedScheduledPostWillBeCreated(
|
||||
rctx request.CTX,
|
||||
scheduledPost *model.ScheduledPost,
|
||||
callerName string,
|
||||
buildRejectionErr func(reason string) *model.AppError,
|
||||
) (*model.ScheduledPost, *model.AppError) {
|
||||
// Channel the guard is resolved for; reused for the inactive-plugin log below.
|
||||
originalChannelID := scheduledPost.ChannelId
|
||||
|
||||
guards, rejectErr := a.resolveGuards(rctx, originalChannelID, callerName)
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, rejectErr
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionError *model.AppError
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
replacement, reason := hooks.ScheduledPostWillBeCreated(pCtx, scheduledPost)
|
||||
if reason != "" {
|
||||
rejectionError = buildRejectionErr(reason)
|
||||
return false
|
||||
}
|
||||
if replacement != nil {
|
||||
scheduledPost = replacement
|
||||
}
|
||||
return true
|
||||
}, plugin.ScheduledPostWillBeCreatedID)
|
||||
if rejectionError != nil {
|
||||
return nil, rejectionError
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, logAndErrPluginInactive(rctx, originalChannelID, []string{g.PluginId}, callerName)
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.ScheduledPostWillBeCreatedWithRPCErr(pCtx, scheduledPost)
|
||||
if rpcErr != nil {
|
||||
return nil, appErrHookFailed(g.PluginId, callerName, rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return nil, buildRejectionErr(reason)
|
||||
}
|
||||
// If replacement == nil && reason == "" && rpcErr == nil, the claimant had no opinion
|
||||
// (did not implement the hook). Do not treat as rejection — continue iterating.
|
||||
if replacement != nil {
|
||||
scheduledPost = replacement
|
||||
}
|
||||
}
|
||||
|
||||
return scheduledPost, nil
|
||||
}
|
||||
|
||||
// runGuardedDraftWillBeUpserted dispatches DraftWillBeUpserted. Returns the (possibly
|
||||
// replaced) draft, or an AppError on rejection or RPC failure.
|
||||
func (a *App) runGuardedDraftWillBeUpserted(rctx request.CTX, draft *model.Draft) (*model.Draft, *model.AppError) {
|
||||
// Channel the guard is resolved for; reused for the inactive-plugin log below.
|
||||
originalChannelID := draft.ChannelId
|
||||
|
||||
guards, rejectErr := a.resolveGuards(rctx, originalChannelID, "UpsertDraft")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return nil, rejectErr
|
||||
}
|
||||
|
||||
buildRejectionErr := func(reason string) *model.AppError {
|
||||
return model.NewAppError("UpsertDraft", "app.draft.upsert.rejected_by_plugin",
|
||||
map[string]any{"Reason": reason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionError *model.AppError
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
replacement, reason := hooks.DraftWillBeUpserted(pCtx, draft)
|
||||
if reason != "" {
|
||||
rejectionError = buildRejectionErr(reason)
|
||||
return false
|
||||
}
|
||||
if replacement != nil {
|
||||
draft = replacement
|
||||
}
|
||||
return true
|
||||
}, plugin.DraftWillBeUpsertedID)
|
||||
if rejectionError != nil {
|
||||
return nil, rejectionError
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return nil, logAndErrPluginInactive(rctx, originalChannelID, []string{g.PluginId}, "UpsertDraft")
|
||||
}
|
||||
replacement, reason, rpcErr := hooks.DraftWillBeUpsertedWithRPCErr(pCtx, draft)
|
||||
if rpcErr != nil {
|
||||
return nil, appErrHookFailed(g.PluginId, "UpsertDraft", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return nil, buildRejectionErr(reason)
|
||||
}
|
||||
// If replacement == nil && reason == "" && rpcErr == nil, the claimant had no opinion
|
||||
// (did not implement the hook). Do not treat as rejection — continue iterating.
|
||||
if replacement != nil {
|
||||
draft = replacement
|
||||
}
|
||||
}
|
||||
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
// runGuardedChannelWillBeRestored dispatches ChannelWillBeRestored. Reject-only — no replacement.
|
||||
func (a *App) runGuardedChannelWillBeRestored(rctx request.CTX, channel *model.Channel) *model.AppError {
|
||||
guards, rejectErr := a.resolveGuards(rctx, channel.Id, "RestoreChannel")
|
||||
|
||||
// Guard plugin is unavailable — fail-closed (logged with attribution).
|
||||
if rejectErr != nil {
|
||||
return rejectErr
|
||||
}
|
||||
|
||||
// Phase A: fan out to non-guard plugins, fail-open. With empty guards the exclude list is
|
||||
// empty and behavior is identical to plain RunMultiHook.
|
||||
var rejectionReason string
|
||||
pCtx := pluginContext(rctx)
|
||||
a.ch.RunMultiHookExcluding(pluginIDsOf(guards), func(hooks plugin.Hooks, _ *model.Manifest) bool {
|
||||
rejectionReason = hooks.ChannelWillBeRestored(pCtx, channel)
|
||||
return rejectionReason == ""
|
||||
}, plugin.ChannelWillBeRestoredID)
|
||||
if rejectionReason != "" {
|
||||
return model.NewAppError("RestoreChannel", "app.channel.restore_channel.rejected_by_plugin",
|
||||
map[string]any{"Reason": rejectionReason}, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Phase B: call each guard claimant in PluginId-sorted order, fail-closed.
|
||||
for _, g := range guards {
|
||||
hooks, err := a.Channels().HooksForPluginWithRPCErr(g.PluginId)
|
||||
if err != nil {
|
||||
// Active→inactive race: plugin deactivated between resolveGuards and now.
|
||||
return logAndErrPluginInactive(rctx, channel.Id, []string{g.PluginId}, "RestoreChannel")
|
||||
}
|
||||
reason, rpcErr := hooks.ChannelWillBeRestoredWithRPCErr(pCtx, channel)
|
||||
if rpcErr != nil {
|
||||
return appErrHookFailed(g.PluginId, "RestoreChannel", rpcErr)
|
||||
}
|
||||
if reason != "" {
|
||||
return model.NewAppError("RestoreChannel", "app.channel.restore_channel.rejected_by_plugin",
|
||||
map[string]any{"Reason": reason}, "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user