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.
503 lines
18 KiB
Go
503 lines
18 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"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"
|
|
)
|
|
|
|
// maxMmBlocksActionsCloneDepth caps recursion in cloneMmBlocksActionsProp.
|
|
// ValidateMmBlocksActions bounds top-level entry count and key length but
|
|
// does not bound nesting depth inside spec.Context — a bot/plugin could
|
|
// otherwise stash a pathologically nested object that drives stack
|
|
// exhaustion on the restore path. 64 is well past any plausible legitimate
|
|
// nesting; deeper input is treated as malicious and truncated.
|
|
const maxMmBlocksActionsCloneDepth = 64
|
|
|
|
// cloneMmBlocksActionsProp deep-clones the post.props.mm_blocks_actions value.
|
|
// Each per-action entry can carry nested context / query maps (and arrays
|
|
// inside those), so the clone walks the structure recursively — a shallow
|
|
// clone at any level would leave nested objects aliased back to the live
|
|
// post's props, defeating the restore-after-invalid-response guarantee.
|
|
func cloneMmBlocksActionsProp(v any) any {
|
|
return cloneMmBlocksActionsPropAt(v, 0)
|
|
}
|
|
|
|
func cloneMmBlocksActionsPropAt(v any, depth int) any {
|
|
if depth > maxMmBlocksActionsCloneDepth {
|
|
// Defense-in-depth: drop the subtree rather than risk stack
|
|
// exhaustion. The restore path that calls this helper is on a
|
|
// rare branch (plugin response is invalid), and pathological
|
|
// nesting at this depth is not a legitimate use case.
|
|
return nil
|
|
}
|
|
switch typed := v.(type) {
|
|
case map[string]any:
|
|
out := make(map[string]any, len(typed))
|
|
for k, child := range typed {
|
|
out[k] = cloneMmBlocksActionsPropAt(child, depth+1)
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]any, len(typed))
|
|
for i, child := range typed {
|
|
out[i] = cloneMmBlocksActionsPropAt(child, depth+1)
|
|
}
|
|
return out
|
|
default:
|
|
// Scalars (string/number/bool/nil) are immutable — safe to share.
|
|
return v
|
|
}
|
|
}
|
|
|
|
// postActionSetup holds resolved state for one interactive post action before the upstream HTTP call.
|
|
type postActionSetup struct {
|
|
upstreamURL string
|
|
upstreamRequest *model.PostActionIntegrationRequest
|
|
datasource string
|
|
retain map[string]any
|
|
remove []string
|
|
originalProps map[string]any
|
|
originalIsPinned bool
|
|
originalHasReactions bool
|
|
rootPostId string
|
|
ephemeralInteractive bool
|
|
ephemeralChannelID string
|
|
ephemeralRootPostID string
|
|
ephemeralSetUserID bool
|
|
}
|
|
|
|
func (a *App) resolvePostActionSetup(
|
|
rctx request.CTX,
|
|
postID, actionID, userID string,
|
|
legacyCookie *model.PostActionCookie,
|
|
mmBlocksCookie *model.MmBlocksActionCookie,
|
|
clientQuery map[string]string,
|
|
integrationFormat string,
|
|
) (*postActionSetup, string, *model.AppError) {
|
|
upstreamRequest := &model.PostActionIntegrationRequest{
|
|
UserId: userID,
|
|
PostId: postID,
|
|
}
|
|
|
|
userChan := a.startUpstreamUserFetch(rctx, userID)
|
|
postChan := a.startUpstreamPostFetch(rctx, postID)
|
|
channelChan := a.startUpstreamChannelFetch(postID)
|
|
|
|
postResult := <-postChan
|
|
if postResult.NErr != nil {
|
|
setup, gotoURL, appErr := a.resolvePostActionSetupFromCookies(postID, actionID, legacyCookie, mmBlocksCookie, clientQuery, upstreamRequest, postResult)
|
|
return a.finishPostActionSetup(setup, gotoURL, appErr, userChan)
|
|
}
|
|
|
|
post := postResult.Data
|
|
chResult := <-channelChan
|
|
if chResult.NErr != nil {
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "app.channel.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(chResult.NErr)
|
|
}
|
|
|
|
setup, gotoURL, appErr := a.resolvePostActionSetupFromPost(post, chResult.Data, actionID, clientQuery, integrationFormat, upstreamRequest)
|
|
return a.finishPostActionSetup(setup, gotoURL, appErr, userChan)
|
|
}
|
|
|
|
func (a *App) startUpstreamPostFetch(rctx request.CTX, postID string) <-chan store.StoreResult[*model.Post] {
|
|
postChan := make(chan store.StoreResult[*model.Post], 1)
|
|
go func() {
|
|
post, err := a.Srv().Store().Post().GetSingle(rctx, postID, false)
|
|
postChan <- store.StoreResult[*model.Post]{Data: post, NErr: err}
|
|
close(postChan)
|
|
}()
|
|
return postChan
|
|
}
|
|
|
|
func (a *App) startUpstreamChannelFetch(postID string) <-chan store.StoreResult[*model.Channel] {
|
|
channelChan := make(chan store.StoreResult[*model.Channel], 1)
|
|
go func() {
|
|
channel, err := a.Srv().Store().Channel().GetForPost(postID)
|
|
channelChan <- store.StoreResult[*model.Channel]{Data: channel, NErr: err}
|
|
close(channelChan)
|
|
}()
|
|
return channelChan
|
|
}
|
|
|
|
func (a *App) startUpstreamUserFetch(rctx request.CTX, userID string) <-chan store.StoreResult[*model.User] {
|
|
userChan := make(chan store.StoreResult[*model.User], 1)
|
|
go func() {
|
|
user, err := a.Srv().Store().User().Get(rctx, userID)
|
|
userChan <- store.StoreResult[*model.User]{Data: user, NErr: err}
|
|
close(userChan)
|
|
}()
|
|
return userChan
|
|
}
|
|
|
|
func (a *App) startUpstreamTeamFetch(teamID string) <-chan store.StoreResult[*model.Team] {
|
|
if teamID == "" {
|
|
return nil
|
|
}
|
|
|
|
teamChan := make(chan store.StoreResult[*model.Team], 1)
|
|
go func() {
|
|
defer close(teamChan)
|
|
|
|
team, err := a.Srv().Store().Team().Get(teamID)
|
|
teamChan <- store.StoreResult[*model.Team]{Data: team, NErr: err}
|
|
}()
|
|
return teamChan
|
|
}
|
|
|
|
func (a *App) populateUpstreamRequestIdentity(
|
|
upstreamRequest *model.PostActionIntegrationRequest,
|
|
userChan <-chan store.StoreResult[*model.User],
|
|
) *model.AppError {
|
|
teamChan := a.startUpstreamTeamFetch(upstreamRequest.TeamId)
|
|
|
|
ur := <-userChan
|
|
if ur.NErr != nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(ur.NErr, &nfErr):
|
|
return model.NewAppError("DoPostActionWithCookie", MissingAccountError, nil, "", http.StatusNotFound).Wrap(ur.NErr)
|
|
default:
|
|
return model.NewAppError("DoPostActionWithCookie", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(ur.NErr)
|
|
}
|
|
}
|
|
upstreamRequest.UserName = ur.Data.Username
|
|
|
|
if teamChan != nil {
|
|
if tr, ok := <-teamChan; ok {
|
|
if tr.NErr != nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(tr.NErr, &nfErr):
|
|
return model.NewAppError("DoPostActionWithCookie", "app.team.get.find.app_error", nil, "", http.StatusNotFound).Wrap(tr.NErr)
|
|
default:
|
|
return model.NewAppError("DoPostActionWithCookie", "app.team.get.finding.app_error", nil, "", http.StatusInternalServerError).Wrap(tr.NErr)
|
|
}
|
|
}
|
|
upstreamRequest.TeamName = tr.Data.Name
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) finishPostActionSetup(
|
|
setup *postActionSetup,
|
|
gotoURL string,
|
|
appErr *model.AppError,
|
|
userChan <-chan store.StoreResult[*model.User],
|
|
) (*postActionSetup, string, *model.AppError) {
|
|
if appErr != nil {
|
|
return nil, "", appErr
|
|
}
|
|
if gotoURL != "" {
|
|
return nil, gotoURL, nil
|
|
}
|
|
if appErr := a.populateUpstreamRequestIdentity(setup.upstreamRequest, userChan); appErr != nil {
|
|
return nil, "", appErr
|
|
}
|
|
return setup, "", nil
|
|
}
|
|
|
|
func (a *App) resolvePostActionSetupFromCookies(
|
|
postID, actionID string,
|
|
legacyCookie *model.PostActionCookie,
|
|
mmBlocksCookie *model.MmBlocksActionCookie,
|
|
clientQuery map[string]string,
|
|
upstreamRequest *model.PostActionIntegrationRequest,
|
|
postResult store.StoreResult[*model.Post],
|
|
) (*postActionSetup, string, *model.AppError) {
|
|
if legacyCookie == nil && mmBlocksCookie == nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(postResult.NErr, &nfErr):
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(postResult.NErr)
|
|
default:
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(postResult.NErr)
|
|
}
|
|
}
|
|
if legacyCookie != nil && mmBlocksCookie != nil {
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "multiple action cookies set", http.StatusBadRequest)
|
|
}
|
|
|
|
var nfPost *store.ErrNotFound
|
|
ephemeral := errors.As(postResult.NErr, &nfPost)
|
|
|
|
if legacyCookie != nil {
|
|
return a.setupFromLegacyCookie(postID, legacyCookie, upstreamRequest, ephemeral)
|
|
}
|
|
return a.setupFromMmBlocksCookie(postID, actionID, mmBlocksCookie, clientQuery, upstreamRequest, ephemeral)
|
|
}
|
|
|
|
func (a *App) setupFromLegacyCookie(
|
|
postID string,
|
|
cookie *model.PostActionCookie,
|
|
upstreamRequest *model.PostActionIntegrationRequest,
|
|
ephemeral bool,
|
|
) (*postActionSetup, string, *model.AppError) {
|
|
if cookie.Integration == nil {
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "no Integration in action cookie", http.StatusBadRequest)
|
|
}
|
|
if postID != cookie.PostId {
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "postId doesn't match", http.StatusBadRequest)
|
|
}
|
|
|
|
channel, appErr := a.channelForPostAction(cookie.ChannelId)
|
|
if appErr != nil {
|
|
return nil, "", appErr
|
|
}
|
|
|
|
fillUpstreamChannel(upstreamRequest, channel, cookie.ChannelId)
|
|
upstreamRequest.Type = cookie.Type
|
|
// Clone the Context map — later code may add selected_option to it, and
|
|
// we must not mutate the shared source.
|
|
upstreamRequest.Context = maps.Clone(cookie.Integration.Context)
|
|
|
|
setup := &postActionSetup{
|
|
upstreamURL: cookie.Integration.URL,
|
|
upstreamRequest: upstreamRequest,
|
|
datasource: cookie.DataSource,
|
|
retain: cookie.RetainProps,
|
|
remove: cookie.RemoveProps,
|
|
rootPostId: cookie.RootPostId,
|
|
ephemeralInteractive: ephemeral,
|
|
ephemeralChannelID: cookie.ChannelId,
|
|
ephemeralRootPostID: cookie.RootPostId,
|
|
ephemeralSetUserID: true,
|
|
}
|
|
return setup, "", nil
|
|
}
|
|
|
|
func (a *App) setupFromMmBlocksCookie(
|
|
postID, actionID string,
|
|
cookie *model.MmBlocksActionCookie,
|
|
clientQuery map[string]string,
|
|
upstreamRequest *model.PostActionIntegrationRequest,
|
|
ephemeral bool,
|
|
) (*postActionSetup, string, *model.AppError) {
|
|
if cookie.Kind != model.MmBlocksActionCookieKind {
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "invalid mm_blocks action cookie", http.StatusBadRequest)
|
|
}
|
|
if postID != cookie.PostId {
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "postId doesn't match", http.StatusBadRequest)
|
|
}
|
|
|
|
channel, appErr := a.channelForPostAction(cookie.ChannelId)
|
|
if appErr != nil {
|
|
return nil, "", appErr
|
|
}
|
|
|
|
mmSpec := cookie.ActionSpec(actionID)
|
|
resolved, err := model.ResolveMmBlocksAction(mmSpec, actionID, clientQuery)
|
|
if appErr := mmBlocksResolveAppError(actionID, err); appErr != nil {
|
|
return nil, "", appErr
|
|
}
|
|
if resolved.OpenURLGoto != "" {
|
|
return nil, resolved.OpenURLGoto, nil
|
|
}
|
|
|
|
fillUpstreamChannel(upstreamRequest, channel, cookie.ChannelId)
|
|
upstreamRequest.Type = model.PostActionTypeButton
|
|
upstreamRequest.Context = resolved.Context
|
|
|
|
setup := &postActionSetup{
|
|
upstreamURL: resolved.ExternalURL,
|
|
upstreamRequest: upstreamRequest,
|
|
retain: cookie.RetainProps,
|
|
remove: cookie.RemoveProps,
|
|
rootPostId: cookie.RootPostId,
|
|
ephemeralInteractive: ephemeral,
|
|
ephemeralChannelID: cookie.ChannelId,
|
|
ephemeralRootPostID: cookie.RootPostId,
|
|
}
|
|
return setup, "", nil
|
|
}
|
|
|
|
func (a *App) resolvePostActionSetupFromPost(
|
|
post *model.Post,
|
|
channel *model.Channel,
|
|
actionID string,
|
|
clientQuery map[string]string,
|
|
integrationFormat string,
|
|
upstreamRequest *model.PostActionIntegrationRequest,
|
|
) (*postActionSetup, string, *model.AppError) {
|
|
fillUpstreamChannel(upstreamRequest, channel, post.ChannelId)
|
|
|
|
switch model.NormalizePostActionIntegrationFormat(integrationFormat) {
|
|
case model.PostActionIntegrationFormatMmBlock,
|
|
model.PostActionIntegrationFormatBlock,
|
|
model.PostActionIntegrationFormatCard:
|
|
if !a.Config().FeatureFlags.MmBlocksEnabled {
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "mm_blocks are not enabled", http.StatusBadRequest)
|
|
}
|
|
mmSpec := post.GetMmBlocksActionSpec(actionID)
|
|
resolved, err := model.ResolveMmBlocksAction(mmSpec, actionID, clientQuery)
|
|
if appErr := mmBlocksResolveAppError(actionID, err); appErr != nil {
|
|
return nil, "", appErr
|
|
}
|
|
if resolved.OpenURLGoto != "" {
|
|
return nil, resolved.OpenURLGoto, nil
|
|
}
|
|
|
|
preserve := post.PostActionPreserveState()
|
|
upstreamRequest.Type = model.PostActionTypeButton
|
|
upstreamRequest.Context = resolved.Context
|
|
|
|
return &postActionSetup{
|
|
upstreamURL: resolved.ExternalURL,
|
|
upstreamRequest: upstreamRequest,
|
|
retain: preserve.Retain,
|
|
remove: preserve.Remove,
|
|
originalProps: preserve.OriginalProps,
|
|
originalIsPinned: preserve.OriginalIsPinned,
|
|
originalHasReactions: preserve.OriginalHasReactions,
|
|
rootPostId: preserve.RootPostId,
|
|
}, "", nil
|
|
|
|
case model.PostActionIntegrationFormatAttachment:
|
|
action := post.GetAction(actionID)
|
|
if action == nil || action.Integration == nil {
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("action=%v", action), http.StatusNotFound)
|
|
}
|
|
|
|
preserve := post.PostActionPreserveState()
|
|
upstreamRequest.Type = action.Type
|
|
// Clone the Context map — the action pointer returned from
|
|
// post.GetAction may alias post.props state. Mutating it directly
|
|
// would leak per-click values (selected_option) into the post's
|
|
// cached integration for subsequent clickers.
|
|
upstreamRequest.Context = maps.Clone(action.Integration.Context)
|
|
|
|
return &postActionSetup{
|
|
upstreamURL: action.Integration.URL,
|
|
upstreamRequest: upstreamRequest,
|
|
datasource: action.DataSource,
|
|
retain: preserve.Retain,
|
|
remove: preserve.Remove,
|
|
originalProps: preserve.OriginalProps,
|
|
originalIsPinned: preserve.OriginalIsPinned,
|
|
originalHasReactions: preserve.OriginalHasReactions,
|
|
rootPostId: preserve.RootPostId,
|
|
}, "", nil
|
|
}
|
|
|
|
return nil, "", model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("integration_context=%s", integrationFormat), http.StatusBadRequest)
|
|
}
|
|
|
|
func fillUpstreamChannel(req *model.PostActionIntegrationRequest, channel *model.Channel, channelID string) {
|
|
req.ChannelId = channelID
|
|
req.ChannelName = channel.Name
|
|
req.TeamId = channel.TeamId
|
|
}
|
|
|
|
func (a *App) channelForPostAction(channelID string) (*model.Channel, *model.AppError) {
|
|
channel, err := a.Srv().Store().Channel().Get(channelID, true)
|
|
if err != nil {
|
|
errCtx := map[string]any{"channel_id": channelID}
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(err, &nfErr):
|
|
return nil, model.NewAppError("DoPostActionWithCookie", "app.channel.get.existing.app_error", errCtx, "", http.StatusNotFound).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("DoPostActionWithCookie", "app.channel.get.find.app_error", errCtx, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
return channel, nil
|
|
}
|
|
|
|
func mmBlocksResolveAppError(actionID string, err error) *model.AppError {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
switch {
|
|
case errors.Is(err, model.ErrMmBlocksActionNotFound):
|
|
return model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_id.app_error", nil, fmt.Sprintf("mm_blocks action_id=%s", actionID), http.StatusNotFound)
|
|
default:
|
|
return model.NewAppError("DoPostActionWithCookie", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
}
|
|
}
|
|
|
|
func (a *App) applyPostActionUpdate(
|
|
rctx request.CTX,
|
|
setup *postActionSetup,
|
|
postID, userID string,
|
|
update *model.Post,
|
|
) *model.AppError {
|
|
update.Id = postID
|
|
|
|
if update.GetProps() == nil {
|
|
switch {
|
|
case setup.originalProps != nil:
|
|
update.SetProps(setup.originalProps)
|
|
case setup.ephemeralInteractive && len(setup.retain) > 0:
|
|
props := make(model.StringInterface)
|
|
maps.Copy(props, setup.retain)
|
|
update.SetProps(props)
|
|
default:
|
|
update.SetProps(setup.originalProps)
|
|
}
|
|
} else {
|
|
for key, value := range setup.retain {
|
|
update.AddProp(key, value)
|
|
}
|
|
for _, key := range setup.remove {
|
|
update.DelProp(key)
|
|
}
|
|
}
|
|
update.IsPinned = setup.originalIsPinned
|
|
update.HasReactions = setup.originalHasReactions
|
|
|
|
// Validate mm_blocks_actions on update responses. Since
|
|
// AllowMmBlocksActionsUpdate bypasses the non-integration guard in
|
|
// UpdatePost, and mm_blocks_actions are not in PostActionRetainPropKeys,
|
|
// a bad response would otherwise permanently replace the post's valid
|
|
// mm_blocks_actions. Keep the original value (if any) and log a warning
|
|
// so integration authors can diagnose.
|
|
if update.GetProp(model.PostPropsMmBlocksActions) != nil {
|
|
originalMmBlocks := any(nil)
|
|
if setup.originalProps != nil {
|
|
originalMmBlocks = setup.originalProps[model.PostPropsMmBlocksActions]
|
|
}
|
|
if originalMmBlocks == nil {
|
|
rctx.Logger().Info("Dropping mm_blocks_actions from plugin update response: original post had none",
|
|
mlog.String("post_id", postID),
|
|
mlog.String("url", setup.upstreamURL),
|
|
)
|
|
update.DelProp(model.PostPropsMmBlocksActions)
|
|
} else if err := model.ValidateMmBlocksActions(update); err != nil {
|
|
rctx.Logger().Info("Restoring original mm_blocks_actions: plugin update response was invalid",
|
|
mlog.String("post_id", postID),
|
|
mlog.String("url", setup.upstreamURL),
|
|
mlog.Err(err),
|
|
)
|
|
update.AddProp(model.PostPropsMmBlocksActions, cloneMmBlocksActionsProp(originalMmBlocks))
|
|
}
|
|
}
|
|
|
|
if setup.ephemeralInteractive {
|
|
update.ChannelId = setup.ephemeralChannelID
|
|
if update.RootId == "" && setup.ephemeralRootPostID != "" && setup.ephemeralRootPostID != postID {
|
|
update.RootId = setup.ephemeralRootPostID
|
|
}
|
|
if setup.ephemeralSetUserID && update.UserId == "" {
|
|
update.UserId = userID
|
|
}
|
|
a.UpdateEphemeralPost(rctx, userID, update)
|
|
return nil
|
|
}
|
|
|
|
if _, _, appErr := a.UpdatePost(rctx, update, &model.UpdatePostOptions{SafeUpdate: false, AllowMmBlocksActionsUpdate: true}); appErr != nil {
|
|
return appErr
|
|
}
|
|
return nil
|
|
}
|