* Adds OmitConnection parameter to broadcast Currently we have no means to omit sending a websocket event to a specific connection id. This is needed mainly so that the initiator won't receive an event for the action it just initiated. Will be used for the global drafts feature, so that we won't update drafts through ws when a user is typing. This commit adds OmitConnection to the Broadcast struct and to the NewWebSocketEvent function signature. shouldSendEvent should return false for that specific connection. * Return early only if connection id matches the omitted Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
2084 строки
72 KiB
Go
2084 строки
72 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mattermost/mattermost-server/v6/app/request"
|
|
"github.com/mattermost/mattermost-server/v6/model"
|
|
"github.com/mattermost/mattermost-server/v6/plugin"
|
|
"github.com/mattermost/mattermost-server/v6/product"
|
|
"github.com/mattermost/mattermost-server/v6/services/cache"
|
|
"github.com/mattermost/mattermost-server/v6/shared/i18n"
|
|
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
|
"github.com/mattermost/mattermost-server/v6/store"
|
|
"github.com/mattermost/mattermost-server/v6/store/sqlstore"
|
|
)
|
|
|
|
const (
|
|
PendingPostIDsCacheSize = 25000
|
|
PendingPostIDsCacheTTL = 30 * time.Second
|
|
PageDefault = 0
|
|
)
|
|
|
|
var atMentionPattern = regexp.MustCompile(`\B@`)
|
|
|
|
// Ensure post service wrapper implements `product.PostService`
|
|
var _ product.PostService = (*postServiceWrapper)(nil)
|
|
|
|
// postServiceWrapper provides an implementation of `product.PostService` for use by products.
|
|
type postServiceWrapper struct {
|
|
app AppIface
|
|
}
|
|
|
|
func (s *postServiceWrapper) CreatePost(ctx *request.Context, post *model.Post) (*model.Post, *model.AppError) {
|
|
return s.app.CreatePostMissingChannel(ctx, post, true)
|
|
}
|
|
|
|
func (a *App) CreatePostAsUser(c request.CTX, post *model.Post, currentSessionId string, setOnline bool) (*model.Post, *model.AppError) {
|
|
// Check that channel has not been deleted
|
|
channel, errCh := a.Srv().Store.Channel().Get(post.ChannelId, true)
|
|
if errCh != nil {
|
|
err := model.NewAppError("CreatePostAsUser", "api.context.invalid_param.app_error", map[string]any{"Name": "post.channel_id"}, "", http.StatusBadRequest).Wrap(errCh)
|
|
return nil, err
|
|
}
|
|
|
|
if strings.HasPrefix(post.Type, model.PostSystemMessagePrefix) {
|
|
err := model.NewAppError("CreatePostAsUser", "api.context.invalid_param.app_error", map[string]any{"Name": "post.type"}, "", http.StatusBadRequest)
|
|
return nil, err
|
|
}
|
|
|
|
if channel.DeleteAt != 0 {
|
|
err := model.NewAppError("createPost", "api.post.create_post.can_not_post_to_deleted.error", nil, "", http.StatusBadRequest)
|
|
return nil, err
|
|
}
|
|
|
|
rp, err := a.CreatePost(c, post, channel, true, setOnline)
|
|
if err != nil {
|
|
if err.Id == "api.post.create_post.root_id.app_error" ||
|
|
err.Id == "api.post.create_post.channel_root_id.app_error" {
|
|
err.StatusCode = http.StatusBadRequest
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
// Update the Channel LastViewAt only if:
|
|
// the post does NOT have from_webhook prop set (e.g. Zapier app), and
|
|
// the post does NOT have from_bot set (e.g. from discovering the user is a bot within CreatePost), and
|
|
// the post is NOT a reply post with CRT enabled
|
|
_, fromWebhook := post.GetProps()["from_webhook"]
|
|
_, fromBot := post.GetProps()["from_bot"]
|
|
isCRTReply := post.RootId != "" && a.IsCRTEnabledForUser(c, post.UserId)
|
|
if !fromWebhook && !fromBot && !isCRTReply {
|
|
if _, err := a.MarkChannelsAsViewed(c, []string{post.ChannelId}, post.UserId, currentSessionId, true); err != nil {
|
|
c.Logger().Warn(
|
|
"Encountered error updating last viewed",
|
|
mlog.String("channel_id", post.ChannelId),
|
|
mlog.String("user_id", post.UserId),
|
|
mlog.Err(err),
|
|
)
|
|
}
|
|
}
|
|
|
|
return rp, nil
|
|
}
|
|
|
|
func (a *App) CreatePostMissingChannel(c request.CTX, post *model.Post, triggerWebhooks bool) (*model.Post, *model.AppError) {
|
|
channel, err := a.Srv().Store.Channel().Get(post.ChannelId, true)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(err, &nfErr):
|
|
return nil, model.NewAppError("CreatePostMissingChannel", "app.channel.get.existing.app_error", nil, "", http.StatusNotFound).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("CreatePostMissingChannel", "app.channel.get.find.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
return a.CreatePost(c, post, channel, triggerWebhooks, true)
|
|
}
|
|
|
|
// deduplicateCreatePost attempts to make posting idempotent within a caching window.
|
|
func (a *App) deduplicateCreatePost(post *model.Post) (foundPost *model.Post, err *model.AppError) {
|
|
// We rely on the client sending the pending post id across "duplicate" requests. If there
|
|
// isn't one, we can't deduplicate, so allow creation normally.
|
|
if post.PendingPostId == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
const unknownPostId = ""
|
|
|
|
// Query the cache atomically for the given pending post id, saving a record if
|
|
// it hasn't previously been seen.
|
|
var postID string
|
|
nErr := a.Srv().seenPendingPostIdsCache.Get(post.PendingPostId, &postID)
|
|
if nErr == cache.ErrKeyNotFound {
|
|
a.Srv().seenPendingPostIdsCache.SetWithExpiry(post.PendingPostId, unknownPostId, PendingPostIDsCacheTTL)
|
|
return nil, nil
|
|
}
|
|
|
|
if nErr != nil {
|
|
return nil, model.NewAppError("errorGetPostId", "api.post.error_get_post_id.pending", nil, "", http.StatusInternalServerError)
|
|
}
|
|
|
|
// If another thread saved the cache record, but hasn't yet updated it with the actual post
|
|
// id (because it's still saving), notify the client with an error. Ideally, we'd wait
|
|
// for the other thread, but coordinating that adds complexity to the happy path.
|
|
if postID == unknownPostId {
|
|
return nil, model.NewAppError("deduplicateCreatePost", "api.post.deduplicate_create_post.pending", nil, "", http.StatusInternalServerError)
|
|
}
|
|
|
|
// If the other thread finished creating the post, return the created post back to the
|
|
// client, making the API call feel idempotent.
|
|
actualPost, err := a.GetSinglePost(postID, false)
|
|
if err != nil {
|
|
return nil, model.NewAppError("deduplicateCreatePost", "api.post.deduplicate_create_post.failed_to_get", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
mlog.Debug("Deduplicated create post", mlog.String("post_id", actualPost.Id), mlog.String("pending_post_id", post.PendingPostId))
|
|
|
|
return actualPost, nil
|
|
}
|
|
|
|
func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel, triggerWebhooks, setOnline bool) (savedPost *model.Post, err *model.AppError) {
|
|
foundPost, err := a.deduplicateCreatePost(post)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if foundPost != nil {
|
|
return foundPost, nil
|
|
}
|
|
|
|
// If we get this far, we've recorded the client-provided pending post id to the cache.
|
|
// Remove it if we fail below, allowing a proper retry by the client.
|
|
defer func() {
|
|
if post.PendingPostId == "" {
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
a.Srv().seenPendingPostIdsCache.Remove(post.PendingPostId)
|
|
return
|
|
}
|
|
|
|
a.Srv().seenPendingPostIdsCache.SetWithExpiry(post.PendingPostId, savedPost.Id, PendingPostIDsCacheTTL)
|
|
}()
|
|
|
|
post.SanitizeProps()
|
|
|
|
var pchan chan store.StoreResult
|
|
if post.RootId != "" {
|
|
pchan = make(chan store.StoreResult, 1)
|
|
go func() {
|
|
r, pErr := a.Srv().Store.Post().Get(sqlstore.WithMaster(context.Background()), post.RootId, model.GetPostsOptions{}, "", a.Config().GetSanitizeOptions())
|
|
pchan <- store.StoreResult{Data: r, NErr: pErr}
|
|
close(pchan)
|
|
}()
|
|
}
|
|
|
|
user, nErr := a.Srv().Store.User().Get(context.Background(), post.UserId)
|
|
if nErr != nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(nErr, &nfErr):
|
|
return nil, model.NewAppError("CreatePost", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr)
|
|
default:
|
|
return nil, model.NewAppError("CreatePost", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
|
}
|
|
}
|
|
|
|
if user.IsBot {
|
|
post.AddProp("from_bot", "true")
|
|
}
|
|
|
|
var ephemeralPost *model.Post
|
|
if post.Type == "" && !a.HasPermissionToChannel(c, user.Id, channel.Id, model.PermissionUseChannelMentions) {
|
|
mention := post.DisableMentionHighlights()
|
|
if mention != "" {
|
|
T := i18n.GetUserTranslations(user.Locale)
|
|
ephemeralPost = &model.Post{
|
|
UserId: user.Id,
|
|
RootId: post.RootId,
|
|
ChannelId: channel.Id,
|
|
Message: T("model.post.channel_notifications_disabled_in_channel.message", model.StringInterface{"ChannelName": channel.Name, "Mention": mention}),
|
|
Props: model.StringInterface{model.PostPropsMentionHighlightDisabled: true},
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify the parent/child relationships are correct
|
|
var parentPostList *model.PostList
|
|
if pchan != nil {
|
|
result := <-pchan
|
|
if result.NErr != nil {
|
|
return nil, model.NewAppError("createPost", "api.post.create_post.root_id.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
parentPostList = result.Data.(*model.PostList)
|
|
if len(parentPostList.Posts) == 0 || !parentPostList.IsChannelId(post.ChannelId) {
|
|
return nil, model.NewAppError("createPost", "api.post.create_post.channel_root_id.app_error", nil, "", http.StatusInternalServerError)
|
|
}
|
|
|
|
rootPost := parentPostList.Posts[post.RootId]
|
|
if rootPost.RootId != "" {
|
|
return nil, model.NewAppError("createPost", "api.post.create_post.root_id.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
post.Hashtags, _ = model.ParseHashtags(post.Message)
|
|
|
|
if err = a.FillInPostProps(c, post, channel); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Temporary fix so old plugins don't clobber new fields in SlackAttachment struct, see MM-13088
|
|
if attachments, ok := post.GetProp("attachments").([]*model.SlackAttachment); ok {
|
|
jsonAttachments, err := json.Marshal(attachments)
|
|
if err == nil {
|
|
attachmentsInterface := []any{}
|
|
err = json.Unmarshal(jsonAttachments, &attachmentsInterface)
|
|
post.AddProp("attachments", attachmentsInterface)
|
|
}
|
|
if err != nil {
|
|
c.Logger().Warn("Could not convert post attachments to map interface.", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil {
|
|
var rejectionError *model.AppError
|
|
pluginContext := pluginContext(c)
|
|
pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool {
|
|
replacementPost, rejectionReason := hooks.MessageWillBePosted(pluginContext, post.ForPlugin())
|
|
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 {
|
|
// the original post's metadata (if there ever was any) is lost, and will be rebuilt.
|
|
post = replacementPost
|
|
}
|
|
|
|
return true
|
|
}, plugin.MessageWillBePostedID)
|
|
|
|
if rejectionError != nil {
|
|
return nil, rejectionError
|
|
}
|
|
}
|
|
|
|
// Pre-fill the CreateAt field for link previews to get the correct timestamp.
|
|
if post.CreateAt == 0 {
|
|
post.CreateAt = model.GetMillis()
|
|
}
|
|
|
|
post = a.getEmbedsAndImages(c, post, true)
|
|
previewPost := post.GetPreviewPost()
|
|
if previewPost != nil {
|
|
post.AddProp(model.PostPropsPreviewedPost, previewPost.PostID)
|
|
}
|
|
|
|
rpost, nErr := a.Srv().Store.Post().Save(post)
|
|
if nErr != nil {
|
|
var appErr *model.AppError
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(nErr, &appErr):
|
|
return nil, appErr
|
|
case errors.As(nErr, &invErr):
|
|
return nil, model.NewAppError("CreatePost", "app.post.save.existing.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
|
|
default:
|
|
return nil, model.NewAppError("CreatePost", "app.post.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
|
}
|
|
}
|
|
|
|
// Update the mapping from pending post id to the actual post id, for any clients that
|
|
// might be duplicating requests.
|
|
a.Srv().seenPendingPostIdsCache.SetWithExpiry(post.PendingPostId, rpost.Id, PendingPostIDsCacheTTL)
|
|
|
|
// We make a copy of the post for the plugin hook to avoid a race condition,
|
|
// and to remove the non-GOB-encodable Metadata from it.
|
|
if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil {
|
|
pluginPost := rpost.ForPlugin()
|
|
a.Srv().Go(func() {
|
|
pluginContext := pluginContext(c)
|
|
pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool {
|
|
hooks.MessageHasBeenPosted(pluginContext, pluginPost)
|
|
return true
|
|
}, plugin.MessageHasBeenPostedID)
|
|
})
|
|
}
|
|
|
|
if a.Metrics() != nil {
|
|
a.Metrics().IncrementPostCreate()
|
|
}
|
|
|
|
if len(post.FileIds) > 0 {
|
|
if err = a.attachFilesToPost(post); err != nil {
|
|
c.Logger().Warn("Encountered error attaching files to post", mlog.String("post_id", post.Id), mlog.Any("file_ids", post.FileIds), mlog.Err(err))
|
|
}
|
|
|
|
if a.Metrics() != nil {
|
|
a.Metrics().IncrementPostFileAttachment(len(post.FileIds))
|
|
}
|
|
}
|
|
|
|
// Normally, we would let the API layer call PreparePostForClient, but we do it here since it also needs
|
|
// to be done when we send the post over the websocket in handlePostEvents
|
|
rpost = a.PreparePostForClient(rpost, true, false)
|
|
|
|
// Make sure poster is following the thread
|
|
if *a.Config().ServiceSettings.ThreadAutoFollow && rpost.RootId != "" {
|
|
_, err := a.Srv().Store.Thread().MaintainMembership(user.Id, rpost.RootId, store.ThreadMembershipOpts{
|
|
Following: true,
|
|
UpdateFollowing: true,
|
|
})
|
|
if err != nil {
|
|
c.Logger().Warn("Failed to update thread membership", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
if err := a.handlePostEvents(c, rpost, user, channel, triggerWebhooks, parentPostList, setOnline); err != nil {
|
|
c.Logger().Warn("Failed to handle post events", mlog.Err(err))
|
|
}
|
|
|
|
// Send any ephemeral posts after the post is created to ensure it shows up after the latest post created
|
|
if ephemeralPost != nil {
|
|
a.SendEphemeralPost(c, post.UserId, ephemeralPost)
|
|
}
|
|
|
|
rpost, err = a.SanitizePostMetadataForUser(c, rpost, c.Session().UserId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return rpost, nil
|
|
}
|
|
|
|
func (a *App) addPostPreviewProp(post *model.Post) (*model.Post, error) {
|
|
previewPost := post.GetPreviewPost()
|
|
if previewPost != nil {
|
|
updatedPost := post.Clone()
|
|
updatedPost.AddProp(model.PostPropsPreviewedPost, previewPost.PostID)
|
|
updatedPost, err := a.Srv().Store.Post().Update(updatedPost, post)
|
|
return updatedPost, err
|
|
}
|
|
return post, nil
|
|
}
|
|
|
|
func (a *App) attachFilesToPost(post *model.Post) *model.AppError {
|
|
var attachedIds []string
|
|
for _, fileID := range post.FileIds {
|
|
err := a.Srv().Store.FileInfo().AttachToPost(fileID, post.Id, post.UserId)
|
|
if err != nil {
|
|
mlog.Warn("Failed to attach file to post", mlog.String("file_id", fileID), mlog.String("post_id", post.Id), mlog.Err(err))
|
|
continue
|
|
}
|
|
|
|
attachedIds = append(attachedIds, fileID)
|
|
}
|
|
|
|
if len(post.FileIds) != len(attachedIds) {
|
|
// We couldn't attach all files to the post, so ensure that post.FileIds reflects what was actually attached
|
|
post.FileIds = attachedIds
|
|
|
|
if _, err := a.Srv().Store.Post().Overwrite(post); err != nil {
|
|
return model.NewAppError("attachFilesToPost", "app.post.overwrite.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FillInPostProps should be invoked before saving posts to fill in properties such as
|
|
// channel_mentions.
|
|
//
|
|
// If channel is nil, FillInPostProps will look up the channel corresponding to the post.
|
|
func (a *App) FillInPostProps(c request.CTX, post *model.Post, channel *model.Channel) *model.AppError {
|
|
channelMentions := post.ChannelMentions()
|
|
channelMentionsProp := make(map[string]any)
|
|
|
|
if len(channelMentions) > 0 {
|
|
if channel == nil {
|
|
postChannel, err := a.Srv().Store.Channel().GetForPost(post.Id)
|
|
if err != nil {
|
|
return model.NewAppError("FillInPostProps", "api.context.invalid_param.app_error", map[string]any{"Name": "post.channel_id"}, "", http.StatusBadRequest).Wrap(err)
|
|
}
|
|
channel = postChannel
|
|
}
|
|
|
|
mentionedChannels, err := a.GetChannelsByNames(c, channelMentions, channel.TeamId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, mentioned := range mentionedChannels {
|
|
if mentioned.Type == model.ChannelTypeOpen {
|
|
team, err := a.Srv().Store.Team().Get(mentioned.TeamId)
|
|
if err != nil {
|
|
mlog.Warn("Failed to get team of the channel mention", mlog.String("team_id", channel.TeamId), mlog.String("channel_id", channel.Id), mlog.Err(err))
|
|
continue
|
|
}
|
|
channelMentionsProp[mentioned.Name] = map[string]any{
|
|
"display_name": mentioned.DisplayName,
|
|
"team_name": team.Name,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(channelMentionsProp) > 0 {
|
|
post.AddProp("channel_mentions", channelMentionsProp)
|
|
} else if post.GetProps() != nil {
|
|
post.DelProp("channel_mentions")
|
|
}
|
|
|
|
matched := atMentionPattern.MatchString(post.Message)
|
|
if a.Srv().License() != nil && *a.Srv().License().Features.LDAPGroups && matched && !a.HasPermissionToChannel(c, post.UserId, post.ChannelId, model.PermissionUseGroupMentions) {
|
|
post.AddProp(model.PostPropsGroupHighlightDisabled, true)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) handlePostEvents(c request.CTX, post *model.Post, user *model.User, channel *model.Channel, triggerWebhooks bool, parentPostList *model.PostList, setOnline bool) error {
|
|
var team *model.Team
|
|
if channel.TeamId != "" {
|
|
t, err := a.Srv().Store.Team().Get(channel.TeamId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
team = t
|
|
} else {
|
|
// Blank team for DMs
|
|
team = &model.Team{}
|
|
}
|
|
|
|
a.invalidateCacheForChannel(channel)
|
|
a.invalidateCacheForChannelPosts(channel.Id)
|
|
|
|
if _, err := a.SendNotifications(c, post, team, channel, user, parentPostList, setOnline); err != nil {
|
|
return err
|
|
}
|
|
|
|
if post.Type != model.PostTypeAutoResponder { // don't respond to an auto-responder
|
|
a.Srv().Go(func() {
|
|
_, err := a.SendAutoResponseIfNecessary(c, channel, user, post)
|
|
if err != nil {
|
|
mlog.Error("Failed to send auto response", mlog.String("user_id", user.Id), mlog.String("post_id", post.Id), mlog.Err(err))
|
|
}
|
|
})
|
|
}
|
|
|
|
if triggerWebhooks {
|
|
a.Srv().Go(func() {
|
|
if err := a.handleWebhookEvents(c, post, team, channel, user); err != nil {
|
|
mlog.Error(err.Error())
|
|
}
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) SendEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post {
|
|
post.Type = model.PostTypeEphemeral
|
|
|
|
// fill in fields which haven't been specified which have sensible defaults
|
|
if post.Id == "" {
|
|
post.Id = model.NewId()
|
|
}
|
|
if post.CreateAt == 0 {
|
|
post.CreateAt = model.GetMillis()
|
|
}
|
|
if post.GetProps() == nil {
|
|
post.SetProps(make(model.StringInterface))
|
|
}
|
|
|
|
post.GenerateActionIds()
|
|
message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", post.ChannelId, userID, nil, "")
|
|
post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false)
|
|
post = model.AddPostActionCookies(post, a.PostActionCookieSecret())
|
|
|
|
postJSON, jsonErr := post.ToJSON()
|
|
if jsonErr != nil {
|
|
mlog.Warn("Failed to encode post to JSON", mlog.Err(jsonErr))
|
|
}
|
|
message.Add("post", postJSON)
|
|
a.Publish(message)
|
|
|
|
return post
|
|
}
|
|
|
|
func (a *App) UpdateEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post {
|
|
post.Type = model.PostTypeEphemeral
|
|
|
|
post.UpdateAt = model.GetMillis()
|
|
if post.GetProps() == nil {
|
|
post.SetProps(make(model.StringInterface))
|
|
}
|
|
|
|
post.GenerateActionIds()
|
|
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", post.ChannelId, userID, nil, "")
|
|
post = a.PreparePostForClientWithEmbedsAndImages(c, post, true, false)
|
|
post = model.AddPostActionCookies(post, a.PostActionCookieSecret())
|
|
postJSON, jsonErr := post.ToJSON()
|
|
if jsonErr != nil {
|
|
mlog.Warn("Failed to encode post to JSON", mlog.Err(jsonErr))
|
|
}
|
|
message.Add("post", postJSON)
|
|
a.Publish(message)
|
|
|
|
return post
|
|
}
|
|
|
|
func (a *App) DeleteEphemeralPost(userID, postID string) {
|
|
post := &model.Post{
|
|
Id: postID,
|
|
UserId: userID,
|
|
Type: model.PostTypeEphemeral,
|
|
DeleteAt: model.GetMillis(),
|
|
UpdateAt: model.GetMillis(),
|
|
}
|
|
|
|
message := model.NewWebSocketEvent(model.WebsocketEventPostDeleted, "", "", userID, nil, "")
|
|
postJSON, jsonErr := post.ToJSON()
|
|
if jsonErr != nil {
|
|
mlog.Warn("Failed to encode post to JSON", mlog.Err(jsonErr))
|
|
}
|
|
message.Add("post", postJSON)
|
|
a.Publish(message)
|
|
}
|
|
|
|
func (a *App) UpdatePost(c *request.Context, post *model.Post, safeUpdate bool) (*model.Post, *model.AppError) {
|
|
post.SanitizeProps()
|
|
|
|
postLists, nErr := a.Srv().Store.Post().Get(context.Background(), post.Id, model.GetPostsOptions{}, "", a.Config().GetSanitizeOptions())
|
|
if nErr != nil {
|
|
var nfErr *store.ErrNotFound
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(nErr, &invErr):
|
|
return nil, model.NewAppError("UpdatePost", "app.post.get.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
|
|
case errors.As(nErr, &nfErr):
|
|
return nil, model.NewAppError("UpdatePost", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(nErr)
|
|
default:
|
|
return nil, model.NewAppError("UpdatePost", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
|
}
|
|
}
|
|
oldPost := postLists.Posts[post.Id]
|
|
|
|
var err *model.AppError
|
|
if oldPost == nil {
|
|
err = model.NewAppError("UpdatePost", "api.post.update_post.find.app_error", nil, "id="+post.Id, http.StatusBadRequest)
|
|
return nil, err
|
|
}
|
|
|
|
if oldPost.DeleteAt != 0 {
|
|
err = model.NewAppError("UpdatePost", "api.post.update_post.permissions_details.app_error", map[string]any{"PostId": post.Id}, "", http.StatusBadRequest)
|
|
return nil, err
|
|
}
|
|
|
|
if oldPost.IsSystemMessage() {
|
|
err = model.NewAppError("UpdatePost", "api.post.update_post.system_message.app_error", nil, "id="+post.Id, http.StatusBadRequest)
|
|
return nil, err
|
|
}
|
|
|
|
if *a.Config().ServiceSettings.PostEditTimeLimit != -1 && model.GetMillis() > oldPost.CreateAt+int64(*a.Config().ServiceSettings.PostEditTimeLimit*1000) && post.Message != oldPost.Message {
|
|
err = model.NewAppError("UpdatePost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *a.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
|
|
return nil, err
|
|
}
|
|
|
|
channel, err := a.GetChannel(c, oldPost.ChannelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if channel.DeleteAt != 0 {
|
|
return nil, model.NewAppError("UpdatePost", "api.post.update_post.can_not_update_post_in_deleted.error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
newPost := oldPost.Clone()
|
|
|
|
if newPost.Message != post.Message {
|
|
newPost.Message = post.Message
|
|
newPost.EditAt = model.GetMillis()
|
|
newPost.Hashtags, _ = model.ParseHashtags(post.Message)
|
|
}
|
|
|
|
if !safeUpdate {
|
|
newPost.IsPinned = post.IsPinned
|
|
newPost.HasReactions = post.HasReactions
|
|
newPost.FileIds = post.FileIds
|
|
newPost.SetProps(post.GetProps())
|
|
}
|
|
|
|
// Avoid deep-equal checks if EditAt was already modified through message change
|
|
if newPost.EditAt == oldPost.EditAt && (!oldPost.FileIds.Equals(newPost.FileIds) || !oldPost.AttachmentsEqual(newPost)) {
|
|
newPost.EditAt = model.GetMillis()
|
|
}
|
|
|
|
if err = a.FillInPostProps(c, post, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if post.IsRemote() {
|
|
oldPost.RemoteId = model.NewString(*post.RemoteId)
|
|
}
|
|
|
|
if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil {
|
|
var rejectionReason string
|
|
pluginContext := pluginContext(c)
|
|
pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool {
|
|
newPost, rejectionReason = hooks.MessageWillBeUpdated(pluginContext, newPost.ForPlugin(), oldPost.ForPlugin())
|
|
return post != nil
|
|
}, plugin.MessageWillBeUpdatedID)
|
|
if newPost == nil {
|
|
return nil, model.NewAppError("UpdatePost", "Post rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest)
|
|
}
|
|
// Restore the post metadata that was stripped by the plugin. Set it to
|
|
// the last known good.
|
|
newPost.Metadata = oldPost.Metadata
|
|
}
|
|
|
|
rpost, nErr := a.Srv().Store.Post().Update(newPost, oldPost)
|
|
if nErr != nil {
|
|
var appErr *model.AppError
|
|
switch {
|
|
case errors.As(nErr, &appErr):
|
|
return nil, appErr
|
|
default:
|
|
return nil, model.NewAppError("UpdatePost", "app.post.update.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
|
}
|
|
}
|
|
|
|
if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil {
|
|
pluginOldPost := oldPost.ForPlugin()
|
|
pluginNewPost := newPost.ForPlugin()
|
|
a.Srv().Go(func() {
|
|
pluginContext := pluginContext(c)
|
|
pluginsEnvironment.RunMultiPluginHook(func(hooks plugin.Hooks) bool {
|
|
hooks.MessageHasBeenUpdated(pluginContext, pluginNewPost, pluginOldPost)
|
|
return true
|
|
}, plugin.MessageHasBeenUpdatedID)
|
|
})
|
|
}
|
|
|
|
rpost = a.PreparePostForClientWithEmbedsAndImages(c, rpost, false, true)
|
|
|
|
// Ensure IsFollowing is nil since this updated post will be broadcast to all users
|
|
// and we don't want to have to populate it for every single user and broadcast to each
|
|
// individually.
|
|
rpost.IsFollowing = nil
|
|
|
|
rpost, nErr = a.addPostPreviewProp(rpost)
|
|
if nErr != nil {
|
|
return nil, model.NewAppError("UpdatePost", "app.post.update.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
|
}
|
|
|
|
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", rpost.ChannelId, "", nil, "")
|
|
postJSON, jsonErr := rpost.ToJSON()
|
|
if jsonErr != nil {
|
|
return nil, model.NewAppError("UpdatePost", "app.post.marshal.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
|
|
}
|
|
message.Add("post", postJSON)
|
|
|
|
published, err := a.publishWebsocketEventForPermalinkPost(c, rpost, message)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !published {
|
|
a.Publish(message)
|
|
}
|
|
|
|
a.invalidateCacheForChannelPosts(rpost.ChannelId)
|
|
|
|
return rpost, nil
|
|
}
|
|
|
|
func (a *App) publishWebsocketEventForPermalinkPost(c request.CTX, post *model.Post, message *model.WebSocketEvent) (published bool, err *model.AppError) {
|
|
var previewedPostID string
|
|
if val, ok := post.GetProp(model.PostPropsPreviewedPost).(string); ok {
|
|
previewedPostID = val
|
|
} else {
|
|
return false, nil
|
|
}
|
|
|
|
if !model.IsValidId(previewedPostID) {
|
|
mlog.Warn("invalid post prop value", mlog.String("prop_key", model.PostPropsPreviewedPost), mlog.String("prop_value", previewedPostID))
|
|
return false, nil
|
|
}
|
|
|
|
previewedPost, err := a.GetSinglePost(previewedPostID, false)
|
|
if err != nil {
|
|
if err.StatusCode == http.StatusNotFound {
|
|
mlog.Warn("permalinked post not found", mlog.String("referenced_post_id", previewedPostID))
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
channelMembers, err := a.GetChannelMembersPage(c, post.ChannelId, 0, 10000000)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
permalinkPreviewedChannel, err := a.GetChannel(c, previewedPost.ChannelId)
|
|
if err != nil {
|
|
if err.StatusCode == http.StatusNotFound {
|
|
mlog.Warn("channel containing permalinked post not found", mlog.String("referenced_channel_id", previewedPost.ChannelId))
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
permalinkPreviewedPost := post.GetPreviewPost()
|
|
for _, cm := range channelMembers {
|
|
if permalinkPreviewedPost != nil {
|
|
post.Metadata.Embeds[0].Data = permalinkPreviewedPost
|
|
}
|
|
|
|
postForUser := a.sanitizePostMetadataForUserAndChannel(c, post, permalinkPreviewedPost, permalinkPreviewedChannel, cm.UserId)
|
|
|
|
// Using DeepCopy here to avoid a race condition
|
|
// between publishing the event and setting the "post" data value below.
|
|
messageCopy := message.DeepCopy()
|
|
broadcastCopy := messageCopy.GetBroadcast()
|
|
broadcastCopy.UserId = cm.UserId
|
|
messageCopy.SetBroadcast(broadcastCopy)
|
|
|
|
postJSON, jsonErr := postForUser.ToJSON()
|
|
if jsonErr != nil {
|
|
mlog.Warn("Failed to encode post to JSON", mlog.Err(jsonErr))
|
|
}
|
|
messageCopy.Add("post", postJSON)
|
|
a.Publish(messageCopy)
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (a *App) PatchPost(c *request.Context, postID string, patch *model.PostPatch) (*model.Post, *model.AppError) {
|
|
post, err := a.GetSinglePost(postID, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
channel, err := a.GetChannel(c, post.ChannelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if channel.DeleteAt != 0 {
|
|
err = model.NewAppError("PatchPost", "api.post.patch_post.can_not_update_post_in_deleted.error", nil, "", http.StatusBadRequest)
|
|
return nil, err
|
|
}
|
|
|
|
if !a.HasPermissionToChannel(c, post.UserId, post.ChannelId, model.PermissionUseChannelMentions) {
|
|
patch.DisableMentionHighlights()
|
|
}
|
|
|
|
post.Patch(patch)
|
|
|
|
updatedPost, err := a.UpdatePost(c, post, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return updatedPost, nil
|
|
}
|
|
|
|
func (a *App) GetPostsPage(options model.GetPostsOptions) (*model.PostList, *model.AppError) {
|
|
postList, err := a.Srv().Store.Post().GetPosts(options, false, a.Config().GetSanitizeOptions())
|
|
if err != nil {
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(err, &invErr):
|
|
return nil, model.NewAppError("GetPostsPage", "app.post.get_posts.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("GetPostsPage", "app.post.get_root_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
// The postList is sorted as only rootPosts Order is included
|
|
if appErr := a.filterInaccessiblePosts(postList, filterPostOptions{assumeSortedCreatedAt: true}); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) GetPosts(channelID string, offset int, limit int) (*model.PostList, *model.AppError) {
|
|
postList, err := a.Srv().Store.Post().GetPosts(model.GetPostsOptions{ChannelId: channelID, Page: offset, PerPage: limit}, true, a.Config().GetSanitizeOptions())
|
|
if err != nil {
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(err, &invErr):
|
|
return nil, model.NewAppError("GetPosts", "app.post.get_posts.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("GetPosts", "app.post.get_root_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
if appErr := a.filterInaccessiblePosts(postList, filterPostOptions{}); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) GetPostsEtag(channelID string, collapsedThreads bool) string {
|
|
return a.Srv().Store.Post().GetEtag(channelID, true, collapsedThreads)
|
|
}
|
|
|
|
func (a *App) GetPostsSince(options model.GetPostsSinceOptions) (*model.PostList, *model.AppError) {
|
|
postList, err := a.Srv().Store.Post().GetPostsSince(options, true, a.Config().GetSanitizeOptions())
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetPostsSince", "app.post.get_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
if appErr := a.filterInaccessiblePosts(postList, filterPostOptions{assumeSortedCreatedAt: true}); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) GetSinglePost(postID string, includeDeleted bool) (*model.Post, *model.AppError) {
|
|
post, err := a.Srv().Store.Post().GetSingle(postID, includeDeleted)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(err, &nfErr):
|
|
return nil, model.NewAppError("GetSinglePost", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("GetSinglePost", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
firstInaccessiblePostTime, appErr := a.isInaccessiblePost(post)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
if firstInaccessiblePostTime != 0 {
|
|
return nil, model.NewAppError("GetSinglePost", "app.post.cloud.get.app_error", nil, "", http.StatusForbidden)
|
|
}
|
|
|
|
return post, nil
|
|
}
|
|
|
|
func (a *App) GetPostThread(postID string, opts model.GetPostsOptions, userID string) (*model.PostList, *model.AppError) {
|
|
posts, err := a.Srv().Store.Post().Get(context.Background(), postID, opts, userID, a.Config().GetSanitizeOptions())
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(err, &invErr):
|
|
return nil, model.NewAppError("GetPostThread", "app.post.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
case errors.As(err, &nfErr):
|
|
return nil, model.NewAppError("GetPostThread", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("GetPostThread", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Get inserts the requested post first in the list, then adds the sorted threadPosts.
|
|
// So, the whole postList.Order is not sorted.
|
|
// The fully sorted list comes only when the CollapsedThreads is true and the Directions is not empty.
|
|
filterOptions := filterPostOptions{}
|
|
if opts.CollapsedThreads && opts.Direction != "" {
|
|
filterOptions.assumeSortedCreatedAt = true
|
|
}
|
|
|
|
if appErr := a.filterInaccessiblePosts(posts, filterOptions); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return posts, nil
|
|
}
|
|
|
|
func (a *App) GetFlaggedPosts(userID string, offset int, limit int) (*model.PostList, *model.AppError) {
|
|
postList, err := a.Srv().Store.Post().GetFlaggedPosts(userID, offset, limit)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetFlaggedPosts", "app.post.get_flagged_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
if appErr := a.filterInaccessiblePosts(postList, filterPostOptions{assumeSortedCreatedAt: true}); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) GetFlaggedPostsForTeam(userID, teamID string, offset int, limit int) (*model.PostList, *model.AppError) {
|
|
postList, err := a.Srv().Store.Post().GetFlaggedPostsForTeam(userID, teamID, offset, limit)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetFlaggedPostsForTeam", "app.post.get_flagged_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
if appErr := a.filterInaccessiblePosts(postList, filterPostOptions{assumeSortedCreatedAt: true}); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) GetFlaggedPostsForChannel(userID, channelID string, offset int, limit int) (*model.PostList, *model.AppError) {
|
|
postList, err := a.Srv().Store.Post().GetFlaggedPostsForChannel(userID, channelID, offset, limit)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetFlaggedPostsForChannel", "app.post.get_flagged_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
if appErr := a.filterInaccessiblePosts(postList, filterPostOptions{assumeSortedCreatedAt: true}); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) GetPermalinkPost(c request.CTX, postID string, userID string) (*model.PostList, *model.AppError) {
|
|
list, nErr := a.Srv().Store.Post().Get(context.Background(), postID, model.GetPostsOptions{}, userID, a.Config().GetSanitizeOptions())
|
|
if nErr != nil {
|
|
var nfErr *store.ErrNotFound
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(nErr, &invErr):
|
|
return nil, model.NewAppError("GetPermalinkPost", "app.post.get.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
|
|
case errors.As(nErr, &nfErr):
|
|
return nil, model.NewAppError("GetPermalinkPost", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(nErr)
|
|
default:
|
|
return nil, model.NewAppError("GetPermalinkPost", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
|
}
|
|
}
|
|
|
|
if len(list.Order) != 1 {
|
|
return nil, model.NewAppError("getPermalinkTmp", "api.post_get_post_by_id.get.app_error", nil, "", http.StatusNotFound)
|
|
}
|
|
post := list.Posts[list.Order[0]]
|
|
|
|
channel, err := a.GetChannel(c, post.ChannelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err = a.JoinChannel(c, channel, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if appErr := a.filterInaccessiblePosts(list, filterPostOptions{assumeSortedCreatedAt: true}); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return list, nil
|
|
}
|
|
|
|
func (a *App) GetPostsBeforePost(options model.GetPostsOptions) (*model.PostList, *model.AppError) {
|
|
postList, err := a.Srv().Store.Post().GetPostsBefore(options, a.Config().GetSanitizeOptions())
|
|
if err != nil {
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(err, &invErr):
|
|
return nil, model.NewAppError("GetPostsBeforePost", "app.post.get_posts_around.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("GetPostsBeforePost", "app.post.get_posts_around.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
// GetPostsBefore orders by channel id and deleted at,
|
|
// before sorting based on created at.
|
|
// but the deleted at is only ever where deleted at = 0,
|
|
// and channel id may or may not be empty (all channels) or defined (single channel),
|
|
// so we can still optimize if the search is for a single channel
|
|
filterOptions := filterPostOptions{}
|
|
if options.ChannelId != "" {
|
|
filterOptions.assumeSortedCreatedAt = true
|
|
}
|
|
if appErr := a.filterInaccessiblePosts(postList, filterOptions); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) GetPostsAfterPost(options model.GetPostsOptions) (*model.PostList, *model.AppError) {
|
|
postList, err := a.Srv().Store.Post().GetPostsAfter(options, a.Config().GetSanitizeOptions())
|
|
if err != nil {
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(err, &invErr):
|
|
return nil, model.NewAppError("GetPostsAfterPost", "app.post.get_posts_around.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("GetPostsAfterPost", "app.post.get_posts_around.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
// GetPostsAfter orders by channel id and deleted at,
|
|
// before sorting based on created at.
|
|
// but the deleted at is only ever where deleted at = 0,
|
|
// and channel id may or may not be empty (all channels) or defined (single channel),
|
|
// so we can still optimize if the search is for a single channel
|
|
filterOptions := filterPostOptions{}
|
|
if options.ChannelId != "" {
|
|
filterOptions.assumeSortedCreatedAt = true
|
|
}
|
|
if appErr := a.filterInaccessiblePosts(postList, filterOptions); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) GetPostsAroundPost(before bool, options model.GetPostsOptions) (*model.PostList, *model.AppError) {
|
|
var postList *model.PostList
|
|
var err error
|
|
sanitize := a.Config().GetSanitizeOptions()
|
|
if before {
|
|
postList, err = a.Srv().Store.Post().GetPostsBefore(options, sanitize)
|
|
} else {
|
|
postList, err = a.Srv().Store.Post().GetPostsAfter(options, sanitize)
|
|
}
|
|
|
|
if err != nil {
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(err, &invErr):
|
|
return nil, model.NewAppError("GetPostsAroundPost", "app.post.get_posts_around.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("GetPostsAroundPost", "app.post.get_posts_around.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
// GetPostsBefore and GetPostsAfter order by channel id and deleted at,
|
|
// before sorting based on created at.
|
|
// but the deleted at is only ever where deleted at = 0,
|
|
// and channel id may or may not be empty (all channels) or defined (single channel),
|
|
// so we can still optimize if the search is for a single channel
|
|
filterOptions := filterPostOptions{}
|
|
if options.ChannelId != "" {
|
|
filterOptions.assumeSortedCreatedAt = true
|
|
}
|
|
if appErr := a.filterInaccessiblePosts(postList, filterOptions); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) GetPostAfterTime(channelID string, time int64, collapsedThreads bool) (*model.Post, *model.AppError) {
|
|
post, err := a.Srv().Store.Post().GetPostAfterTime(channelID, time, collapsedThreads)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetPostAfterTime", "app.post.get_post_after_time.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
return post, nil
|
|
}
|
|
|
|
func (a *App) GetPostIdAfterTime(channelID string, time int64, collapsedThreads bool) (string, *model.AppError) {
|
|
postID, err := a.Srv().Store.Post().GetPostIdAfterTime(channelID, time, collapsedThreads)
|
|
if err != nil {
|
|
return "", model.NewAppError("GetPostIdAfterTime", "app.post.get_post_id_around.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
return postID, nil
|
|
}
|
|
|
|
func (a *App) GetPostIdBeforeTime(channelID string, time int64, collapsedThreads bool) (string, *model.AppError) {
|
|
postID, err := a.Srv().Store.Post().GetPostIdBeforeTime(channelID, time, collapsedThreads)
|
|
if err != nil {
|
|
return "", model.NewAppError("GetPostIdBeforeTime", "app.post.get_post_id_around.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
return postID, nil
|
|
}
|
|
|
|
func (a *App) GetNextPostIdFromPostList(postList *model.PostList, collapsedThreads bool) string {
|
|
if len(postList.Order) > 0 {
|
|
firstPostId := postList.Order[0]
|
|
firstPost := postList.Posts[firstPostId]
|
|
nextPostId, err := a.GetPostIdAfterTime(firstPost.ChannelId, firstPost.CreateAt, collapsedThreads)
|
|
if err != nil {
|
|
mlog.Warn("GetNextPostIdFromPostList: failed in getting next post", mlog.Err(err))
|
|
}
|
|
|
|
return nextPostId
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (a *App) GetPrevPostIdFromPostList(postList *model.PostList, collapsedThreads bool) string {
|
|
if len(postList.Order) > 0 {
|
|
lastPostId := postList.Order[len(postList.Order)-1]
|
|
lastPost := postList.Posts[lastPostId]
|
|
previousPostId, err := a.GetPostIdBeforeTime(lastPost.ChannelId, lastPost.CreateAt, collapsedThreads)
|
|
if err != nil {
|
|
mlog.Warn("GetPrevPostIdFromPostList: failed in getting previous post", mlog.Err(err))
|
|
}
|
|
|
|
return previousPostId
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// AddCursorIdsForPostList adds NextPostId and PrevPostId as cursor to the PostList.
|
|
// The conditional blocks ensure that it sets those cursor IDs immediately as afterPost, beforePost or empty,
|
|
// and only query to database whenever necessary.
|
|
func (a *App) AddCursorIdsForPostList(originalList *model.PostList, afterPost, beforePost string, since int64, page, perPage int, collapsedThreads bool) {
|
|
prevPostIdSet := false
|
|
prevPostId := ""
|
|
nextPostIdSet := false
|
|
nextPostId := ""
|
|
|
|
if since > 0 { // "since" query to return empty NextPostId and PrevPostId
|
|
nextPostIdSet = true
|
|
prevPostIdSet = true
|
|
} else if afterPost != "" {
|
|
if page == 0 {
|
|
prevPostId = afterPost
|
|
prevPostIdSet = true
|
|
}
|
|
|
|
if len(originalList.Order) < perPage {
|
|
nextPostIdSet = true
|
|
}
|
|
} else if beforePost != "" {
|
|
if page == 0 {
|
|
nextPostId = beforePost
|
|
nextPostIdSet = true
|
|
}
|
|
|
|
if len(originalList.Order) < perPage {
|
|
prevPostIdSet = true
|
|
}
|
|
}
|
|
|
|
if !nextPostIdSet {
|
|
nextPostId = a.GetNextPostIdFromPostList(originalList, collapsedThreads)
|
|
}
|
|
|
|
if !prevPostIdSet {
|
|
prevPostId = a.GetPrevPostIdFromPostList(originalList, collapsedThreads)
|
|
}
|
|
|
|
originalList.NextPostId = nextPostId
|
|
originalList.PrevPostId = prevPostId
|
|
}
|
|
func (a *App) GetPostsForChannelAroundLastUnread(c request.CTX, channelID, userID string, limitBefore, limitAfter int, skipFetchThreads bool, collapsedThreads, collapsedThreadsExtended bool) (*model.PostList, *model.AppError) {
|
|
var member *model.ChannelMember
|
|
var err *model.AppError
|
|
if member, err = a.GetChannelMember(c, channelID, userID); err != nil {
|
|
return nil, err
|
|
} else if member.LastViewedAt == 0 {
|
|
return model.NewPostList(), nil
|
|
}
|
|
|
|
lastUnreadPostId, err := a.GetPostIdAfterTime(channelID, member.LastViewedAt, collapsedThreads)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if lastUnreadPostId == "" {
|
|
return model.NewPostList(), nil
|
|
}
|
|
|
|
opts := model.GetPostsOptions{
|
|
SkipFetchThreads: skipFetchThreads,
|
|
CollapsedThreads: collapsedThreads,
|
|
CollapsedThreadsExtended: collapsedThreadsExtended,
|
|
}
|
|
postList, err := a.GetPostThread(lastUnreadPostId, opts, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Reset order to only include the last unread post: if the thread appears in the centre
|
|
// channel organically, those replies will be added below.
|
|
postList.Order = []string{}
|
|
// Add lastUnreadPostId in order, only if it hasn't been filtered as per the cloud plan's limit
|
|
if _, ok := postList.Posts[lastUnreadPostId]; ok {
|
|
postList.Order = []string{lastUnreadPostId}
|
|
|
|
// BeforePosts will only be accessible if the lastUnreadPostId is itself accessible
|
|
if postListBefore, err := a.GetPostsBeforePost(model.GetPostsOptions{ChannelId: channelID, PostId: lastUnreadPostId, Page: PageDefault, PerPage: limitBefore, SkipFetchThreads: skipFetchThreads, CollapsedThreads: collapsedThreads, CollapsedThreadsExtended: collapsedThreadsExtended, UserId: userID}); err != nil {
|
|
return nil, err
|
|
} else if postListBefore != nil {
|
|
postList.Extend(postListBefore)
|
|
}
|
|
}
|
|
|
|
if postListAfter, err := a.GetPostsAfterPost(model.GetPostsOptions{ChannelId: channelID, PostId: lastUnreadPostId, Page: PageDefault, PerPage: limitAfter - 1, SkipFetchThreads: skipFetchThreads, CollapsedThreads: collapsedThreads, CollapsedThreadsExtended: collapsedThreadsExtended, UserId: userID}); err != nil {
|
|
return nil, err
|
|
} else if postListAfter != nil {
|
|
postList.Extend(postListAfter)
|
|
}
|
|
|
|
postList.SortByCreateAt()
|
|
return postList, nil
|
|
}
|
|
|
|
func (a *App) DeletePost(c request.CTX, postID, deleteByID string) (*model.Post, *model.AppError) {
|
|
post, err := a.Srv().Store.Post().GetSingle(postID, false)
|
|
if err != nil {
|
|
return nil, model.NewAppError("DeletePost", "app.post.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
}
|
|
|
|
channel, appErr := a.GetChannel(c, post.ChannelId)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
if channel.DeleteAt != 0 {
|
|
appErr := model.NewAppError("DeletePost", "api.post.delete_post.can_not_delete_post_in_deleted.error", nil, "", http.StatusBadRequest)
|
|
return nil, appErr
|
|
}
|
|
|
|
err = a.Srv().Store.Post().Delete(postID, model.GetMillis(), deleteByID)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(err, &nfErr):
|
|
return nil, model.NewAppError("DeletePost", "app.post.delete.app_error", nil, "", http.StatusNotFound).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("DeletePost", "app.post.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
postJSON, err := json.Marshal(post)
|
|
if err != nil {
|
|
return nil, model.NewAppError("DeletePost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
userMessage := model.NewWebSocketEvent(model.WebsocketEventPostDeleted, "", post.ChannelId, "", nil, "")
|
|
userMessage.Add("post", string(postJSON))
|
|
userMessage.GetBroadcast().ContainsSanitizedData = true
|
|
a.Publish(userMessage)
|
|
|
|
adminMessage := model.NewWebSocketEvent(model.WebsocketEventPostDeleted, "", post.ChannelId, "", nil, "")
|
|
adminMessage.Add("post", string(postJSON))
|
|
adminMessage.Add("delete_by", deleteByID)
|
|
adminMessage.GetBroadcast().ContainsSensitiveData = true
|
|
a.Publish(adminMessage)
|
|
|
|
if len(post.FileIds) > 0 {
|
|
a.Srv().Go(func() {
|
|
a.deletePostFiles(post.Id)
|
|
})
|
|
a.Srv().Store.FileInfo().InvalidateFileInfosForPostCache(postID, true)
|
|
a.Srv().Store.FileInfo().InvalidateFileInfosForPostCache(postID, false)
|
|
}
|
|
a.Srv().Go(func() {
|
|
a.deleteFlaggedPosts(post.Id)
|
|
})
|
|
|
|
a.invalidateCacheForChannelPosts(post.ChannelId)
|
|
|
|
return post, nil
|
|
}
|
|
|
|
func (a *App) deleteFlaggedPosts(postID string) {
|
|
if err := a.Srv().Store.Preference().DeleteCategoryAndName(model.PreferenceCategoryFlaggedPost, postID); err != nil {
|
|
a.Log().Warn("Unable to delete flagged post preference when deleting post.", mlog.Err(err))
|
|
return
|
|
}
|
|
}
|
|
|
|
func (a *App) deletePostFiles(postID string) {
|
|
if _, err := a.Srv().Store.FileInfo().DeleteForPost(postID); err != nil {
|
|
a.Log().Warn("Encountered error when deleting files for post", mlog.String("post_id", postID), mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func (a *App) parseAndFetchChannelIdByNameFromInFilter(c *request.Context, channelName, userID, teamID string, includeDeleted bool) (*model.Channel, error) {
|
|
if strings.HasPrefix(channelName, "@") && strings.Contains(channelName, ",") {
|
|
var userIDs []string
|
|
users, err := a.GetUsersByUsernames(strings.Split(channelName[1:], ","), false, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, user := range users {
|
|
userIDs = append(userIDs, user.Id)
|
|
}
|
|
|
|
channel, err := a.GetGroupChannel(c, userIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return channel, nil
|
|
}
|
|
|
|
if strings.HasPrefix(channelName, "@") && !strings.Contains(channelName, ",") {
|
|
user, err := a.GetUserByUsername(channelName[1:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
channel, err := a.GetOrCreateDirectChannel(c, userID, user.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return channel, nil
|
|
}
|
|
|
|
channel, err := a.GetChannelByName(c, channelName, teamID, includeDeleted)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return channel, nil
|
|
}
|
|
|
|
func (a *App) searchPostsInTeam(teamID string, userID string, paramsList []*model.SearchParams, modifierFun func(*model.SearchParams)) (*model.PostList, *model.AppError) {
|
|
var wg sync.WaitGroup
|
|
|
|
pchan := make(chan store.StoreResult, len(paramsList))
|
|
|
|
for _, params := range paramsList {
|
|
// Don't allow users to search for everything.
|
|
if params.Terms == "*" {
|
|
continue
|
|
}
|
|
modifierFun(params)
|
|
wg.Add(1)
|
|
|
|
go func(params *model.SearchParams) {
|
|
defer wg.Done()
|
|
postList, err := a.Srv().Store.Post().Search(teamID, userID, params)
|
|
pchan <- store.StoreResult{Data: postList, NErr: err}
|
|
}(params)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(pchan)
|
|
|
|
posts := model.NewPostList()
|
|
|
|
for result := range pchan {
|
|
if result.NErr != nil {
|
|
return nil, model.NewAppError("searchPostsInTeam", "app.post.search.app_error", nil, "", http.StatusInternalServerError).Wrap(result.NErr)
|
|
}
|
|
data := result.Data.(*model.PostList)
|
|
posts.Extend(data)
|
|
}
|
|
|
|
posts.SortByCreateAt()
|
|
|
|
a.filterInaccessiblePosts(posts, filterPostOptions{assumeSortedCreatedAt: true})
|
|
|
|
return posts, nil
|
|
}
|
|
|
|
func (a *App) convertChannelNamesToChannelIds(c *request.Context, channels []string, userID string, teamID string, includeDeletedChannels bool) []string {
|
|
for idx, channelName := range channels {
|
|
channel, err := a.parseAndFetchChannelIdByNameFromInFilter(c, channelName, userID, teamID, includeDeletedChannels)
|
|
if err != nil {
|
|
a.Log().Warn("error getting channel id by name from in filter", mlog.Err(err))
|
|
continue
|
|
}
|
|
channels[idx] = channel.Id
|
|
}
|
|
return channels
|
|
}
|
|
|
|
func (a *App) convertUserNameToUserIds(usernames []string) []string {
|
|
for idx, username := range usernames {
|
|
user, err := a.GetUserByUsername(username)
|
|
if err != nil {
|
|
a.Log().Warn("error getting user by username", mlog.String("user_name", username), mlog.Err(err))
|
|
continue
|
|
}
|
|
usernames[idx] = user.Id
|
|
}
|
|
return usernames
|
|
}
|
|
|
|
// GetLastAccessiblePostTime returns CreateAt time(from cache) of the last accessible post as per the cloud limit
|
|
func (a *App) GetLastAccessiblePostTime() (int64, *model.AppError) {
|
|
license := a.Srv().License()
|
|
if license == nil || !*license.Features.Cloud {
|
|
return 0, nil
|
|
}
|
|
|
|
system, err := a.Srv().Store.System().GetByName(model.SystemLastAccessiblePostTime)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(err, &nfErr):
|
|
// All posts are accessible
|
|
return 0, nil
|
|
default:
|
|
return 0, model.NewAppError("GetLastAccessiblePostTime", "app.system.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
lastAccessiblePostTime, err := strconv.ParseInt(system.Value, 10, 64)
|
|
if err != nil {
|
|
return 0, model.NewAppError("GetLastAccessiblePostTime", "common.parse_error_int64", map[string]interface{}{"Value": system.Value}, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
return lastAccessiblePostTime, nil
|
|
}
|
|
|
|
// ComputeLastAccessiblePostTime updates cache with CreateAt time of the last accessible post as per the cloud plan's limit.
|
|
// Use GetLastAccessiblePostTime() to access the result.
|
|
func (a *App) ComputeLastAccessiblePostTime() error {
|
|
limit, appErr := a.getCloudMessagesHistoryLimit()
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
createdAt, err := a.Srv().GetStore().Post().GetNthRecentPostTime(limit)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
if !errors.As(err, &nfErr) {
|
|
return model.NewAppError("ComputeLastAccessiblePostTime", "app.last_accessible_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Update Cache
|
|
err = a.Srv().Store.System().SaveOrUpdate(&model.System{
|
|
Name: model.SystemLastAccessiblePostTime,
|
|
Value: strconv.FormatInt(createdAt, 10),
|
|
})
|
|
if err != nil {
|
|
return model.NewAppError("ComputeLastAccessiblePostTime", "app.system.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) getCloudMessagesHistoryLimit() (int64, *model.AppError) {
|
|
license := a.Srv().License()
|
|
if license == nil || !*license.Features.Cloud {
|
|
return 0, nil
|
|
}
|
|
|
|
limits, err := a.Cloud().GetCloudLimits("")
|
|
if err != nil {
|
|
return 0, model.NewAppError("getCloudMessagesHistoryLimit", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
if limits == nil || limits.Messages == nil || limits.Messages.History == nil {
|
|
// Cloud limit is not applicable
|
|
return 0, nil
|
|
}
|
|
|
|
return int64(*limits.Messages.History), nil
|
|
}
|
|
|
|
func (a *App) SearchPostsInTeam(teamID string, paramsList []*model.SearchParams) (*model.PostList, *model.AppError) {
|
|
if !*a.Config().ServiceSettings.EnablePostSearch {
|
|
return nil, model.NewAppError("SearchPostsInTeam", "store.sql_post.search.disabled", nil, fmt.Sprintf("teamId=%v", teamID), http.StatusNotImplemented)
|
|
}
|
|
|
|
return a.searchPostsInTeam(teamID, "", paramsList, func(params *model.SearchParams) {
|
|
params.SearchWithoutUserId = true
|
|
})
|
|
}
|
|
|
|
func (a *App) SearchPostsForUser(c *request.Context, terms string, userID string, teamID string, isOrSearch bool, includeDeletedChannels bool, timeZoneOffset int, page, perPage int, modifier string) (*model.PostSearchResults, *model.AppError) {
|
|
var postSearchResults *model.PostSearchResults
|
|
paramsList := model.ParseSearchParams(strings.TrimSpace(terms), timeZoneOffset)
|
|
includeDeleted := includeDeletedChannels && *a.Config().TeamSettings.ExperimentalViewArchivedChannels
|
|
|
|
if !*a.Config().ServiceSettings.EnablePostSearch {
|
|
return nil, model.NewAppError("SearchPostsForUser", "store.sql_post.search.disabled", nil, fmt.Sprintf("teamId=%v userId=%v", teamID, userID), http.StatusNotImplemented)
|
|
}
|
|
|
|
finalParamsList := []*model.SearchParams{}
|
|
|
|
for _, params := range paramsList {
|
|
params.Modifier = modifier
|
|
params.OrTerms = isOrSearch
|
|
params.IncludeDeletedChannels = includeDeleted
|
|
// Don't allow users to search for "*"
|
|
if params.Terms != "*" {
|
|
// TODO: we have to send channel ids
|
|
// from the front-end. Otherwise it's not possible to distinguish
|
|
// from just the channel name at a cross-team level.
|
|
// Convert channel names to channel IDs
|
|
params.InChannels = a.convertChannelNamesToChannelIds(c, params.InChannels, userID, teamID, includeDeletedChannels)
|
|
params.ExcludedChannels = a.convertChannelNamesToChannelIds(c, params.ExcludedChannels, userID, teamID, includeDeletedChannels)
|
|
|
|
// Convert usernames to user IDs
|
|
params.FromUsers = a.convertUserNameToUserIds(params.FromUsers)
|
|
params.ExcludedUsers = a.convertUserNameToUserIds(params.ExcludedUsers)
|
|
|
|
finalParamsList = append(finalParamsList, params)
|
|
}
|
|
}
|
|
|
|
// If the processed search params are empty, return empty search results.
|
|
if len(finalParamsList) == 0 {
|
|
return model.MakePostSearchResults(model.NewPostList(), nil), nil
|
|
}
|
|
|
|
postSearchResults, err := a.Srv().Store.Post().SearchPostsForUser(finalParamsList, userID, teamID, page, perPage)
|
|
if err != nil {
|
|
var appErr *model.AppError
|
|
switch {
|
|
case errors.As(err, &appErr):
|
|
return nil, appErr
|
|
default:
|
|
return nil, model.NewAppError("SearchPostsForUser", "app.post.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
if appErr := a.filterInaccessiblePosts(postSearchResults.PostList, filterPostOptions{assumeSortedCreatedAt: true}); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return postSearchResults, nil
|
|
}
|
|
|
|
func (a *App) GetRecentSearchesForUser(userID string) ([]*model.SearchParams, *model.AppError) {
|
|
searchParams, err := a.Srv().Store.Post().GetRecentSearchesForUser(userID)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetRecentSearchesForUser", "app.recent_searches.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
return searchParams, nil
|
|
}
|
|
|
|
func (a *App) GetFileInfosForPostWithMigration(postID string, includeDeleted bool) ([]*model.FileInfo, *model.AppError) {
|
|
|
|
pchan := make(chan store.StoreResult, 1)
|
|
go func() {
|
|
post, err := a.Srv().Store.Post().GetSingle(postID, includeDeleted)
|
|
pchan <- store.StoreResult{Data: post, NErr: err}
|
|
close(pchan)
|
|
}()
|
|
|
|
infos, err := a.GetFileInfosForPost(postID, false, includeDeleted)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(infos) == 0 {
|
|
// No FileInfos were returned so check if they need to be created for this post
|
|
result := <-pchan
|
|
if result.NErr != nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(result.NErr, &nfErr):
|
|
return nil, model.NewAppError("GetFileInfosForPostWithMigration", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(result.NErr)
|
|
default:
|
|
return nil, model.NewAppError("GetFileInfosForPostWithMigration", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(result.NErr)
|
|
}
|
|
}
|
|
post := result.Data.(*model.Post)
|
|
|
|
if len(post.Filenames) > 0 {
|
|
a.Srv().Store.FileInfo().InvalidateFileInfosForPostCache(postID, false)
|
|
a.Srv().Store.FileInfo().InvalidateFileInfosForPostCache(postID, true)
|
|
// The post has Filenames that need to be replaced with FileInfos
|
|
infos = a.MigrateFilenamesToFileInfos(post)
|
|
}
|
|
}
|
|
|
|
return infos, nil
|
|
}
|
|
|
|
func (a *App) GetFileInfosForPost(postID string, fromMaster bool, includeDeleted bool) ([]*model.FileInfo, *model.AppError) {
|
|
fileInfos, err := a.Srv().Store.FileInfo().GetForPost(postID, fromMaster, includeDeleted, true)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetFileInfosForPost", "app.file_info.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
a.generateMiniPreviewForInfos(fileInfos)
|
|
|
|
return fileInfos, nil
|
|
}
|
|
|
|
func (a *App) PostWithProxyAddedToImageURLs(post *model.Post) *model.Post {
|
|
if f := a.ImageProxyAdder(); f != nil {
|
|
return post.WithRewrittenImageURLs(f)
|
|
}
|
|
return post
|
|
}
|
|
|
|
func (a *App) PostWithProxyRemovedFromImageURLs(post *model.Post) *model.Post {
|
|
if f := a.ImageProxyRemover(); f != nil {
|
|
return post.WithRewrittenImageURLs(f)
|
|
}
|
|
return post
|
|
}
|
|
|
|
func (a *App) PostPatchWithProxyRemovedFromImageURLs(patch *model.PostPatch) *model.PostPatch {
|
|
if f := a.ImageProxyRemover(); f != nil {
|
|
return patch.WithRewrittenImageURLs(f)
|
|
}
|
|
return patch
|
|
}
|
|
|
|
func (a *App) ImageProxyAdder() func(string) string {
|
|
if !*a.Config().ImageProxySettings.Enable {
|
|
return nil
|
|
}
|
|
|
|
return func(url string) string {
|
|
return a.ImageProxy().GetProxiedImageURL(url)
|
|
}
|
|
}
|
|
|
|
func (a *App) ImageProxyRemover() (f func(string) string) {
|
|
if !*a.Config().ImageProxySettings.Enable {
|
|
return nil
|
|
}
|
|
|
|
return func(url string) string {
|
|
return a.ImageProxy().GetUnproxiedImageURL(url)
|
|
}
|
|
}
|
|
|
|
func (s *Server) MaxPostSize() int {
|
|
maxPostSize := s.Store.Post().GetMaxPostSize()
|
|
if maxPostSize == 0 {
|
|
return model.PostMessageMaxRunesV1
|
|
}
|
|
|
|
return maxPostSize
|
|
}
|
|
|
|
func (a *App) MaxPostSize() int {
|
|
return a.Srv().MaxPostSize()
|
|
}
|
|
|
|
// countThreadMentions returns the number of times the user is mentioned in a specified thread after the timestamp.
|
|
func (a *App) countThreadMentions(c request.CTX, user *model.User, post *model.Post, teamID string, timestamp int64) (int64, *model.AppError) {
|
|
channel, err := a.GetChannel(c, post.ChannelId)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
keywords := addMentionKeywordsForUser(
|
|
map[string][]string{},
|
|
user,
|
|
map[string]string{},
|
|
&model.Status{Status: model.StatusOnline}, // Assume the user is online since they would've triggered this
|
|
true, // Assume channel mentions are always allowed for simplicity
|
|
)
|
|
|
|
posts, nErr := a.Srv().Store.Thread().GetPosts(post.Id, timestamp)
|
|
if nErr != nil {
|
|
return 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
|
}
|
|
|
|
count := 0
|
|
|
|
if channel.Type == model.ChannelTypeDirect {
|
|
// In a DM channel, every post made by the other user is a mention
|
|
otherId := channel.GetOtherUserIdForDM(user.Id)
|
|
for _, p := range posts {
|
|
if p.UserId == otherId {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return int64(count), nil
|
|
}
|
|
|
|
var team *model.Team
|
|
if teamID != "" {
|
|
team, err = a.GetTeam(teamID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
groups, nErr := a.getGroupsAllowedForReferenceInChannel(channel, team)
|
|
if nErr != nil {
|
|
return 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
|
}
|
|
|
|
for _, p := range posts {
|
|
if p.CreateAt >= timestamp {
|
|
mentions := getExplicitMentions(p, keywords, groups)
|
|
if _, ok := mentions.Mentions[user.Id]; ok {
|
|
count += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return int64(count), nil
|
|
}
|
|
|
|
// countMentionsFromPost returns the number of posts in the post's channel that mention the user after and including the
|
|
// given post.
|
|
func (a *App) countMentionsFromPost(c request.CTX, user *model.User, post *model.Post) (int, int, *model.AppError) {
|
|
channel, err := a.GetChannel(c, post.ChannelId)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
if channel.Type == model.ChannelTypeDirect {
|
|
// In a DM channel, every post made by the other user is a mention
|
|
count, countRoot, nErr := a.Srv().Store.Channel().CountPostsAfter(post.ChannelId, post.CreateAt-1, channel.GetOtherUserIdForDM(user.Id))
|
|
if nErr != nil {
|
|
return 0, 0, model.NewAppError("countMentionsFromPost", "app.channel.count_posts_since.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
|
|
}
|
|
|
|
return count, countRoot, nil
|
|
}
|
|
|
|
channelMember, err := a.GetChannelMember(c, channel.Id, user.Id)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
keywords := addMentionKeywordsForUser(
|
|
map[string][]string{},
|
|
user,
|
|
channelMember.NotifyProps,
|
|
&model.Status{Status: model.StatusOnline}, // Assume the user is online since they would've triggered this
|
|
true, // Assume channel mentions are always allowed for simplicity
|
|
)
|
|
commentMentions := user.NotifyProps[model.CommentsNotifyProp]
|
|
checkForCommentMentions := commentMentions == model.CommentsNotifyRoot || commentMentions == model.CommentsNotifyAny
|
|
|
|
// A mapping of thread root IDs to whether or not a post in that thread mentions the user
|
|
mentionedByThread := make(map[string]bool)
|
|
|
|
thread, err := a.GetPostThread(post.Id, model.GetPostsOptions{}, user.Id)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
count := 0
|
|
countRoot := 0
|
|
if isPostMention(user, post, keywords, thread.Posts, mentionedByThread, checkForCommentMentions) {
|
|
count += 1
|
|
if post.RootId == "" {
|
|
countRoot += 1
|
|
}
|
|
}
|
|
|
|
page := 0
|
|
perPage := 200
|
|
for {
|
|
postList, err := a.GetPostsAfterPost(model.GetPostsOptions{
|
|
ChannelId: post.ChannelId,
|
|
PostId: post.Id,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
})
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
for _, postID := range postList.Order {
|
|
if isPostMention(user, postList.Posts[postID], keywords, postList.Posts, mentionedByThread, checkForCommentMentions) {
|
|
count += 1
|
|
if postList.Posts[postID].RootId == "" {
|
|
countRoot += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(postList.Order) < perPage {
|
|
break
|
|
}
|
|
|
|
page += 1
|
|
}
|
|
|
|
return count, countRoot, nil
|
|
}
|
|
|
|
func isCommentMention(user *model.User, post *model.Post, otherPosts map[string]*model.Post, mentionedByThread map[string]bool) bool {
|
|
if post.RootId == "" {
|
|
// Not a comment
|
|
return false
|
|
}
|
|
|
|
if mentioned, ok := mentionedByThread[post.RootId]; ok {
|
|
// We've already figured out if the user was mentioned by this thread
|
|
return mentioned
|
|
}
|
|
|
|
// Whether or not the user was mentioned because they started the thread
|
|
mentioned := otherPosts[post.RootId].UserId == user.Id
|
|
|
|
// Or because they commented on it before this post
|
|
if !mentioned && user.NotifyProps[model.CommentsNotifyProp] == model.CommentsNotifyAny {
|
|
for _, otherPost := range otherPosts {
|
|
if otherPost.Id == post.Id {
|
|
continue
|
|
}
|
|
|
|
if otherPost.RootId != post.RootId {
|
|
continue
|
|
}
|
|
|
|
if otherPost.UserId == user.Id && otherPost.CreateAt < post.CreateAt {
|
|
// Found a comment made by the user from before this post
|
|
mentioned = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
mentionedByThread[post.RootId] = mentioned
|
|
return mentioned
|
|
}
|
|
|
|
func isPostMention(user *model.User, post *model.Post, keywords map[string][]string, otherPosts map[string]*model.Post, mentionedByThread map[string]bool, checkForCommentMentions bool) bool {
|
|
// Prevent the user from mentioning themselves
|
|
if post.UserId == user.Id && post.GetProp("from_webhook") != "true" {
|
|
return false
|
|
}
|
|
|
|
// Check for keyword mentions
|
|
mentions := getExplicitMentions(post, keywords, make(map[string]*model.Group))
|
|
if _, ok := mentions.Mentions[user.Id]; ok {
|
|
return true
|
|
}
|
|
|
|
// Check for mentions caused by being added to the channel
|
|
if post.Type == model.PostTypeAddToChannel {
|
|
if addedUserId, ok := post.GetProp(model.PostPropsAddedUserId).(string); ok && addedUserId == user.Id {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check for comment mentions
|
|
if checkForCommentMentions && isCommentMention(user, post, otherPosts, mentionedByThread) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (a *App) GetThreadMembershipsForUser(userID, teamID string) ([]*model.ThreadMembership, error) {
|
|
return a.Srv().Store.Thread().GetMembershipsForUser(userID, teamID)
|
|
}
|
|
|
|
func (a *App) GetPostIfAuthorized(c request.CTX, postID string, session *model.Session, includeDeleted bool) (*model.Post, *model.AppError) {
|
|
post, err := a.GetSinglePost(postID, includeDeleted)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
channel, err := a.GetChannel(c, post.ChannelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !a.SessionHasPermissionToChannel(c, *session, channel.Id, model.PermissionReadChannel) {
|
|
if channel.Type == model.ChannelTypeOpen {
|
|
if !a.SessionHasPermissionToTeam(*session, channel.TeamId, model.PermissionReadPublicChannel) {
|
|
return nil, a.MakePermissionError(session, []*model.Permission{model.PermissionReadPublicChannel})
|
|
}
|
|
} else {
|
|
return nil, a.MakePermissionError(session, []*model.Permission{model.PermissionReadChannel})
|
|
}
|
|
}
|
|
|
|
return post, nil
|
|
}
|
|
|
|
// GetPostsByIds response bool value indicates, if the post is inaccessible due to cloud plan's limit.
|
|
func (a *App) GetPostsByIds(postIDs []string) ([]*model.Post, int64, *model.AppError) {
|
|
posts, err := a.Srv().Store.Post().GetPostsByIds(postIDs)
|
|
if err != nil {
|
|
var nfErr *store.ErrNotFound
|
|
switch {
|
|
case errors.As(err, &nfErr):
|
|
return nil, 0, model.NewAppError("GetPostsByIds", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
|
|
default:
|
|
return nil, 0, model.NewAppError("GetPostsByIds", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
posts, firstInaccessiblePostTime, appErr := a.getFilteredAccessiblePosts(posts, filterPostOptions{assumeSortedCreatedAt: true})
|
|
if appErr != nil {
|
|
return nil, 0, appErr
|
|
}
|
|
|
|
return posts, firstInaccessiblePostTime, nil
|
|
}
|
|
|
|
func (a *App) GetTopThreadsForTeamSince(c request.CTX, teamID, userID string, opts *model.InsightsOpts) (*model.TopThreadList, *model.AppError) {
|
|
if !a.Config().FeatureFlags.InsightsEnabled {
|
|
return nil, model.NewAppError("GetTopChannelsForTeamSince", "app.insights.feature_disabled", nil, "", http.StatusNotImplemented)
|
|
}
|
|
|
|
topThreads, err := a.Srv().Store.Thread().GetTopThreadsForTeamSince(teamID, userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetTopChannelsForTeamSince", "app.post.get_top_threads_for_team_since.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
topThreadsWithEmbedAndImage, err := includeEmbedsAndImages(a, c, topThreads, userID)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetTopChannelsForTeamSince", "app.post.get_top_threads_for_team_since.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return topThreadsWithEmbedAndImage, nil
|
|
}
|
|
|
|
func (a *App) GetTopThreadsForUserSince(c request.CTX, teamID, userID string, opts *model.InsightsOpts) (*model.TopThreadList, *model.AppError) {
|
|
if !a.Config().FeatureFlags.InsightsEnabled {
|
|
return nil, model.NewAppError("GetTopChannelsForTeamSince", "app.insights.feature_disabled", nil, "", http.StatusNotImplemented)
|
|
}
|
|
|
|
topThreads, err := a.Srv().Store.Thread().GetTopThreadsForUserSince(teamID, userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetTopChannelsForTeamSince", "app.post.get_top_threads_for_team_since.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
topThreadsWithEmbedAndImage, err := includeEmbedsAndImages(a, c, topThreads, userID)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetTopChannelsForUserSince", "app.post.get_top_threads_for_user_since.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
return topThreadsWithEmbedAndImage, nil
|
|
}
|
|
|
|
func (a *App) GetTopDMsForUserSince(userID string, opts *model.InsightsOpts) (*model.TopDMList, *model.AppError) {
|
|
if !a.Config().FeatureFlags.InsightsEnabled {
|
|
return nil, model.NewAppError("GetTopDMsForUserSince", "app.insights.feature_disabled", nil, "", http.StatusNotImplemented)
|
|
}
|
|
topDMs, err := a.Srv().Store.Post().GetTopDMsForUserSince(userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetTopDMsForUserSince", "app.post.get_top_dms_for_user_since.app_error", nil, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return topDMs, nil
|
|
}
|
|
|
|
func (a *App) SetPostReminder(postID, userID string, targetTime int64) *model.AppError {
|
|
// Store the reminder in the DB
|
|
reminder := &model.PostReminder{
|
|
PostId: postID,
|
|
UserId: userID,
|
|
TargetTime: targetTime,
|
|
}
|
|
err := a.Srv().Store.Post().SetPostReminder(reminder)
|
|
if err != nil {
|
|
return model.NewAppError("SetPostReminder", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
metadata, err := a.Srv().Store.Post().GetPostReminderMetadata(postID)
|
|
if err != nil {
|
|
return model.NewAppError("SetPostReminder", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
parsed := time.Unix(targetTime, 0).UTC().Format(time.RFC822)
|
|
siteURL := *a.Config().ServiceSettings.SiteURL
|
|
// Send an ack message.
|
|
ephemeralPost := &model.Post{
|
|
Type: model.PostTypeEphemeral,
|
|
Id: model.NewId(),
|
|
CreateAt: model.GetMillis(),
|
|
UserId: userID,
|
|
RootId: postID,
|
|
ChannelId: metadata.ChannelId,
|
|
// It's okay to keep this non-translated. This is just a fallback.
|
|
// The webapp will parse the timestamp and show that in user's local timezone.
|
|
Message: fmt.Sprintf("You will be reminded about %s/%s/pl/%s by @%s at %s", siteURL, metadata.TeamName, postID, metadata.Username, parsed),
|
|
Props: model.StringInterface{
|
|
"target_time": targetTime,
|
|
"team_name": metadata.TeamName,
|
|
"post_id": postID,
|
|
"username": metadata.Username,
|
|
"type": model.PostTypeReminder,
|
|
},
|
|
}
|
|
|
|
message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", ephemeralPost.ChannelId, userID, nil, "")
|
|
ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(request.EmptyContext(a.Log()), ephemeralPost, true, false)
|
|
ephemeralPost = model.AddPostActionCookies(ephemeralPost, a.PostActionCookieSecret())
|
|
|
|
postJSON, jsonErr := ephemeralPost.ToJSON()
|
|
if jsonErr != nil {
|
|
mlog.Warn("Failed to encode post to JSON", mlog.Err(jsonErr))
|
|
}
|
|
message.Add("post", postJSON)
|
|
a.Publish(message)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) CheckPostReminders() {
|
|
systemBot, appErr := a.GetSystemBot()
|
|
if appErr != nil {
|
|
mlog.Error("Failed to get system bot", mlog.Err(appErr))
|
|
return
|
|
}
|
|
|
|
// This will return the reminders and also delete them from the DB.
|
|
// In case, any of the next steps fail, those reminders would be lost.
|
|
// Alternatively, if we delete those reminders _after_ it has been sent,
|
|
// then in case of any temporary failure, they would get sent in the next batch.
|
|
// MM-45595.
|
|
reminders, err := a.Srv().Store.Post().GetPostReminders(time.Now().UTC().Unix())
|
|
if err != nil {
|
|
mlog.Error("Failed to get post reminders", mlog.Err(err))
|
|
return
|
|
}
|
|
|
|
// We group multiple reminders for a single user.
|
|
groupedReminders := make(map[string][]string)
|
|
for _, r := range reminders {
|
|
if groupedReminders[r.UserId] == nil {
|
|
groupedReminders[r.UserId] = []string{r.PostId}
|
|
} else {
|
|
groupedReminders[r.UserId] = append(groupedReminders[r.UserId], r.PostId)
|
|
}
|
|
}
|
|
|
|
siteURL := *a.Config().ServiceSettings.SiteURL
|
|
for userID, postIDs := range groupedReminders {
|
|
ch, appErr := a.GetOrCreateDirectChannel(request.EmptyContext(a.Log()), userID, systemBot.UserId)
|
|
if appErr != nil {
|
|
mlog.Error("Failed to get direct channel", mlog.Err(appErr))
|
|
return
|
|
}
|
|
|
|
for _, postID := range postIDs {
|
|
metadata, err := a.Srv().Store.Post().GetPostReminderMetadata(postID)
|
|
if err != nil {
|
|
mlog.Error("Failed to get post reminder metadata", mlog.Err(err))
|
|
continue
|
|
}
|
|
|
|
T := i18n.GetUserTranslations(metadata.UserLocale)
|
|
dm := &model.Post{
|
|
ChannelId: ch.Id,
|
|
Message: T("app.post_reminder_dm", model.StringInterface{
|
|
"SiteURL": siteURL,
|
|
"TeamName": metadata.TeamName,
|
|
"PostId": postID,
|
|
"Username": metadata.Username,
|
|
}),
|
|
Type: model.PostTypeDefault,
|
|
UserId: systemBot.UserId,
|
|
Props: model.StringInterface{
|
|
"username": systemBot.Username,
|
|
},
|
|
}
|
|
|
|
if _, err := a.CreatePost(request.EmptyContext(a.Log()), dm, ch, false, true); err != nil {
|
|
mlog.Error("Failed to post reminder message", mlog.Err(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func includeEmbedsAndImages(a *App, c request.CTX, topThreadList *model.TopThreadList, userID string) (*model.TopThreadList, error) {
|
|
for _, topThread := range topThreadList.Items {
|
|
topThread.Post = a.PreparePostForClientWithEmbedsAndImages(c, topThread.Post, false, false)
|
|
sanitizedPost, err := a.SanitizePostMetadataForUser(c, topThread.Post, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
topThread.Post = sanitizedPost
|
|
}
|
|
return topThreadList, nil
|
|
}
|