mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-26 13:17:29 -05:00
Add opt-in post property value hydration to GET /posts/{id}
This commit is contained in:
@@ -584,6 +584,11 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
propertyGroupID := resolvePropertyGroupParam(c, r)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
post, err, isMember := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), includeDeleted)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
@@ -596,7 +601,10 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true})
|
||||
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{
|
||||
IncludePriority: true,
|
||||
PropertyGroupID: propertyGroupID,
|
||||
})
|
||||
post, previewIsMember, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type postPropertyTestHelper struct {
|
||||
*TestHelper
|
||||
groupID string
|
||||
}
|
||||
|
||||
func setupPostPropertyTest(t *testing.T) *postPropertyTestHelper {
|
||||
t.Helper()
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PostAttributes = true
|
||||
}).InitBasic(t)
|
||||
|
||||
group, appErr := th.App.GetPropertyGroup(th.Context, model.PostAttributesPropertyGroupName)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
return &postPropertyTestHelper{TestHelper: th, groupID: group.ID}
|
||||
}
|
||||
|
||||
func (th *postPropertyTestHelper) createField(t *testing.T, groupID, name string) *model.PropertyField {
|
||||
t.Helper()
|
||||
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
||||
GroupID: groupID,
|
||||
Name: name,
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypePost,
|
||||
TargetType: string(model.PropertyFieldTargetLevelChannel),
|
||||
TargetID: th.BasicChannel.Id,
|
||||
}, false, "")
|
||||
require.Nil(t, appErr)
|
||||
return field
|
||||
}
|
||||
|
||||
func (th *postPropertyTestHelper) setValue(t *testing.T, groupID, postID, fieldID, raw string) {
|
||||
t.Helper()
|
||||
_, appErr := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{{
|
||||
TargetID: postID,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: groupID,
|
||||
FieldID: fieldID,
|
||||
Value: json.RawMessage(raw),
|
||||
CreatedBy: th.BasicUser.Id,
|
||||
UpdatedBy: th.BasicUser.Id,
|
||||
}}, model.PropertyValueTargetTypePost, postID, "")
|
||||
require.Nil(t, appErr)
|
||||
}
|
||||
|
||||
func TestGetPostWithPropertyGroups(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
t.Run("returns values for the requested group", func(t *testing.T) {
|
||||
th := setupPostPropertyTest(t)
|
||||
field := th.createField(t, th.groupID, "sensitivity")
|
||||
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"confidential"`)
|
||||
|
||||
post, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
|
||||
model.GetPostOptions{IncludePropertyGroups: []string{model.PostAttributesPropertyGroupName}})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, post.Metadata)
|
||||
require.Len(t, post.Metadata.PropertyValues, 1)
|
||||
assert.Equal(t, field.ID, post.Metadata.PropertyValues[0].FieldID)
|
||||
})
|
||||
|
||||
// Etag correctness rests on a write to a property value bumping the post's UpdateAt, since
|
||||
// Post.Etag() is Etag(id, UpdateAt). Until that bump exists, a conditional request serves a
|
||||
// 304 and the client keeps rendering the previous attribute values. The bump is deliberately
|
||||
// out of scope here -- it belongs to the write path -- so this test is the standing record of
|
||||
// what the read path depends on, and it should be un-skipped when that lands.
|
||||
t.Run("changing a value moves the post etag", func(t *testing.T) {
|
||||
t.Skip("Requires property value writes to bump the post's UpdateAt; landing in a follow-up PR")
|
||||
|
||||
th := setupPostPropertyTest(t)
|
||||
groups := []string{model.PostAttributesPropertyGroupName}
|
||||
field := th.createField(t, th.groupID, "sensitivity")
|
||||
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"internal"`)
|
||||
|
||||
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
|
||||
model.GetPostOptions{IncludePropertyGroups: groups})
|
||||
require.NoError(t, err)
|
||||
etag := resp.Etag
|
||||
require.NotEmpty(t, etag)
|
||||
|
||||
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"confidential"`)
|
||||
|
||||
_, resp, err = th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
|
||||
model.GetPostOptions{IncludePropertyGroups: groups})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, etag, resp.Etag,
|
||||
"a value change must bump the post UpdateAt, or conditional requests serve stale values")
|
||||
|
||||
// And a conditional request with the stale etag must return the new value, not a 304.
|
||||
post, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, etag,
|
||||
model.GetPostOptions{IncludePropertyGroups: groups})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.Len(t, post.Metadata.PropertyValues, 1)
|
||||
assert.JSONEq(t, `"confidential"`, string(post.Metadata.PropertyValues[0].Value))
|
||||
})
|
||||
|
||||
t.Run("etags are served whether or not values are requested", func(t *testing.T) {
|
||||
th := setupPostPropertyTest(t)
|
||||
groups := []string{model.PostAttributesPropertyGroupName}
|
||||
field := th.createField(t, th.groupID, "sensitivity")
|
||||
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"confidential"`)
|
||||
|
||||
_, plainResp, err := th.Client.GetPost(context.Background(), th.BasicPost.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, plainResp.Etag)
|
||||
|
||||
_, groupResp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
|
||||
model.GetPostOptions{IncludePropertyGroups: groups})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, groupResp.Etag)
|
||||
assert.Equal(t, plainResp.Etag, groupResp.Etag)
|
||||
|
||||
post, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, groupResp.Etag,
|
||||
model.GetPostOptions{IncludePropertyGroups: groups})
|
||||
require.NoError(t, err)
|
||||
CheckEtag(t, post, resp)
|
||||
})
|
||||
|
||||
t.Run("parameter validation", func(t *testing.T) {
|
||||
th := setupPostPropertyTest(t)
|
||||
|
||||
t.Run("more than one group is rejected before any lookup", func(t *testing.T) {
|
||||
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
|
||||
model.GetPostOptions{IncludePropertyGroups: []string{model.PostAttributesPropertyGroupName, model.BoardsPropertyGroupName}})
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("unknown group is a 404", func(t *testing.T) {
|
||||
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
|
||||
model.GetPostOptions{IncludePropertyGroups: []string{"nope"}})
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("session_attributes stays gated", func(t *testing.T) {
|
||||
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
|
||||
model.GetPostOptions{IncludePropertyGroups: []string{model.SessionAttributesPropertyGroupName}})
|
||||
require.Error(t, err)
|
||||
CheckNotImplementedStatus(t, resp)
|
||||
})
|
||||
})
|
||||
|
||||
// Both options travel on the same request, so the struct is exercised rather than merely
|
||||
// declared: a deleted post returns no values, and the include_deleted permission still applies.
|
||||
t.Run("combines include_deleted with property groups", func(t *testing.T) {
|
||||
th := setupPostPropertyTest(t)
|
||||
opts := model.GetPostOptions{
|
||||
IncludeDeleted: true,
|
||||
IncludePropertyGroups: []string{model.PostAttributesPropertyGroupName},
|
||||
}
|
||||
|
||||
field := th.createField(t, th.groupID, "sensitivity")
|
||||
th.setValue(t, th.groupID, th.BasicPost.Id, field.ID, `"confidential"`)
|
||||
|
||||
_, err := th.SystemAdminClient.DeletePost(context.Background(), th.BasicPost.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// include_deleted still requires manage_system, group or no group.
|
||||
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "", opts)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
post, resp, err := th.SystemAdminClient.GetPostWithOptions(context.Background(), th.BasicPost.Id, "", opts)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.Equal(t, th.BasicPost.Id, post.Id)
|
||||
// A deleted post is never hydrated, and that is not an unavailability either.
|
||||
assert.Empty(t, post.Metadata.PropertyValues)
|
||||
assert.False(t, post.Metadata.PropertyValuesUnavailable)
|
||||
})
|
||||
|
||||
// 4.H — the parameter is group-agnostic by design; any PSAv2 group is fair game.
|
||||
t.Run("accepts any PSAv2 group", func(t *testing.T) {
|
||||
th := setupPostPropertyTest(t)
|
||||
boards, appErr := th.App.GetPropertyGroup(th.Context, model.BoardsPropertyGroupName)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
field := th.createField(t, boards.ID, "board-attr")
|
||||
th.setValue(t, boards.ID, th.BasicPost.Id, field.ID, `"in-progress"`)
|
||||
|
||||
post, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
|
||||
model.GetPostOptions{IncludePropertyGroups: []string{model.BoardsPropertyGroupName}})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.Len(t, post.Metadata.PropertyValues, 1)
|
||||
assert.Equal(t, field.ID, post.Metadata.PropertyValues[0].FieldID)
|
||||
})
|
||||
|
||||
// 4.H2 — content_flagging is a V1 group with its own gated routes. A 200 with no values would
|
||||
// read as "this post has no moderation values", which is exactly the wrong signal, so the
|
||||
// status itself is the assertion.
|
||||
t.Run("a V1 group is rejected rather than returning nothing", func(t *testing.T) {
|
||||
th := setupPostPropertyTest(t)
|
||||
|
||||
_, resp, err := th.Client.GetPostWithOptions(context.Background(), th.BasicPost.Id, "",
|
||||
model.GetPostOptions{IncludePropertyGroups: []string{model.ContentFlaggingGroupName}})
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
|
||||
// And nothing leaks through the generic parameter for a post that does carry V1 values.
|
||||
post, plainResp, err := th.Client.GetPost(context.Background(), th.BasicPost.Id, "")
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, plainResp)
|
||||
assert.Empty(t, post.Metadata.PropertyValues)
|
||||
})
|
||||
}
|
||||
@@ -62,6 +62,35 @@ func getV2Group(c *Context, callerName string) *model.PropertyGroup {
|
||||
return group
|
||||
}
|
||||
|
||||
// resolvePropertyGroupParam reads the include_property_groups query parameter and resolves it to a
|
||||
// property group ID. Returns "" when the parameter is absent, which callers pass straight through to
|
||||
// PreparePostForClientOpts as "do not hydrate". On any validation failure it sets c.Err, so callers
|
||||
// check that rather than the returned value.
|
||||
func resolvePropertyGroupParam(c *Context, r *http.Request) string {
|
||||
raw := r.URL.Query().Get("include_property_groups")
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// The parameter is plural and comma-separated for forward compatibility, but hydrating more
|
||||
// than one group per request is not supported yet. Reject rather than silently taking the
|
||||
// first, which would return a body that does not match what was asked for.
|
||||
names := strings.Split(raw, ",")
|
||||
if len(names) != 1 || strings.TrimSpace(names[0]) == "" {
|
||||
c.Err = model.NewAppError("resolvePropertyGroupParam", "api.post.property_groups.too_many.app_error",
|
||||
nil, "", http.StatusBadRequest)
|
||||
return ""
|
||||
}
|
||||
|
||||
c.Params.GroupName = strings.TrimSpace(names[0])
|
||||
group := getV2Group(c, "resolvePropertyGroupParam")
|
||||
if group == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return group.ID
|
||||
}
|
||||
|
||||
func createPropertyField(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireGroupName().RequireObjectType()
|
||||
if c.Err != nil {
|
||||
|
||||
@@ -185,6 +185,13 @@ func (a *App) OverrideIconURLIfEmoji(rctx request.CTX, post *model.Post) {
|
||||
}
|
||||
|
||||
func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
|
||||
post, _ := a.preparePostForClient(rctx, originalPost, opts)
|
||||
return post
|
||||
}
|
||||
|
||||
// preparePostForClient additionally reports whether a prepare step deliberately blanked the post's
|
||||
// metadata, which callers that batch work across a page need in order to skip those posts.
|
||||
func (a *App) preparePostForClient(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) (*model.Post, bool) {
|
||||
post := originalPost.Clone()
|
||||
|
||||
// Proxy image links before constructing metadata so that requests go through the proxy
|
||||
@@ -195,11 +202,15 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, o
|
||||
post.Metadata = &model.PostMetadata{}
|
||||
}
|
||||
|
||||
// Set when a prepare step deliberately blanks the metadata without returning, so later steps
|
||||
// can tell "nothing to show yet" from "nothing there".
|
||||
metadataWithheld := false
|
||||
|
||||
if post.DeleteAt > 0 && !opts.RetainContent {
|
||||
// For deleted posts we don't fill out metadata nor do we return the post content
|
||||
post.Message = ""
|
||||
post.Metadata = &model.PostMetadata{}
|
||||
return post
|
||||
return post, true
|
||||
}
|
||||
|
||||
// Emojis and reaction counts
|
||||
@@ -232,6 +243,7 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, o
|
||||
// if the post is a scheduled post, we don't reset the metadata
|
||||
} else {
|
||||
post.Metadata = &model.PostMetadata{}
|
||||
metadataWithheld = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,7 +264,11 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, o
|
||||
}
|
||||
}
|
||||
|
||||
return post
|
||||
if opts.PropertyGroupID != "" && !metadataWithheld {
|
||||
a.hydratePropertyValues(rctx, []*model.Post{post}, opts.PropertyGroupID)
|
||||
}
|
||||
|
||||
return post, metadataWithheld
|
||||
}
|
||||
|
||||
func (a *App) preparePostFilesForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
|
||||
@@ -266,10 +282,15 @@ func (a *App) preparePostFilesForClient(rctx request.CTX, post *model.Post, opts
|
||||
}
|
||||
|
||||
func (a *App) PreparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
|
||||
post := a.PreparePostForClient(rctx, originalPost, opts)
|
||||
post, _ := a.preparePostForClientWithEmbedsAndImages(rctx, originalPost, opts)
|
||||
return post
|
||||
}
|
||||
|
||||
func (a *App) preparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) (*model.Post, bool) {
|
||||
post, metadataWithheld := a.preparePostForClient(rctx, originalPost, opts)
|
||||
post = a.getEmbedsAndImages(rctx, post, opts.IsNewPost)
|
||||
post = a.preparePostFilesForClient(rctx, post, opts)
|
||||
return post
|
||||
return post, metadataWithheld
|
||||
}
|
||||
|
||||
func (a *App) getEmbedsAndImages(rctx request.CTX, post *model.Post, isNewPost bool) *model.Post {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
)
|
||||
|
||||
// hydratePropertyValues attaches the group's property values onto each post's metadata.
|
||||
//
|
||||
// Callers pass posts they have already established are eligible to carry values; this function
|
||||
// deliberately does not re-derive any visibility rule.
|
||||
//
|
||||
// It never returns an error. A failed lookup marks the affected posts unavailable and logs, so
|
||||
// that a caller asking for attributes still gets its posts back. A client can then tell "this
|
||||
// post has no values" from "the values could not be loaded" instead of rendering an unmarked post.
|
||||
func (a *App) hydratePropertyValues(rctx request.CTX, posts []*model.Post, groupID string) {
|
||||
eligible := make([]*model.Post, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
if post == nil || post.Metadata == nil {
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, post)
|
||||
}
|
||||
|
||||
if len(eligible) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Identify the viewer so a redaction hook on the group can filter per caller.
|
||||
rctx = RequestContextWithCallerID(rctx, rctx.Session().UserId)
|
||||
|
||||
// Fields are scoped per channel, so posts are grouped by channel.
|
||||
byChannel := map[string][]*model.Post{}
|
||||
for _, post := range eligible {
|
||||
byChannel[post.ChannelId] = append(byChannel[post.ChannelId], post)
|
||||
}
|
||||
|
||||
for channelID, channelPosts := range byChannel {
|
||||
if err := a.hydrateChannelPropertyValues(rctx, channelID, channelPosts, groupID); err != nil {
|
||||
rctx.Logger().Warn(
|
||||
"Failed to hydrate post property values",
|
||||
mlog.String("group_id", groupID),
|
||||
mlog.String("channel_id", channelID),
|
||||
mlog.Int("post_count", len(channelPosts)),
|
||||
mlog.Err(err),
|
||||
)
|
||||
markPropertyValuesUnavailable(channelPosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hydrateChannelPropertyValues hydrates one channel's worth of posts, whose applicable fields are
|
||||
// all the same. Returns an error for the caller to translate into the unavailability marker.
|
||||
func (a *App) hydrateChannelPropertyValues(rctx request.CTX, channelID string, posts []*model.Post, groupID string) error {
|
||||
channel, appErr := a.GetChannel(rctx, channelID)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
fields, appErr := a.SearchPropertyFields(rctx, groupID, model.PropertyFieldSearchOpts{
|
||||
ObjectTypes: []string{model.PropertyFieldObjectTypePost},
|
||||
ChannelID: channelID,
|
||||
TeamID: channel.TeamId,
|
||||
PerPage: model.PostAttributesMaxApplicableFields + 1,
|
||||
})
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
// Over the bound means the per-target cap was bypassed and the page may be truncated, so the
|
||||
// value set below would be silently incomplete. Report unavailable rather than partial.
|
||||
if len(fields) > model.PostAttributesMaxApplicableFields {
|
||||
return model.NewAppError("hydratePropertyValues", "app.post.property_values.field_bound_exceeded.app_error",
|
||||
nil, "applicable field count exceeds the per-post bound", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
applicable := make(map[string]*model.PropertyField, len(fields))
|
||||
for _, field := range fields {
|
||||
applicable[field.ID] = field
|
||||
}
|
||||
|
||||
if len(applicable) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
postIDs := make([]string, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
postIDs = append(postIDs, post.Id)
|
||||
}
|
||||
slices.Sort(postIDs)
|
||||
|
||||
values, appErr := a.SearchPropertyValues(rctx, groupID, model.PropertyValueSearchOpts{
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
TargetIDs: postIDs,
|
||||
PerPage: len(postIDs)*model.PostAttributesMaxApplicableFields + 1,
|
||||
})
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
if len(values) > len(postIDs)*model.PostAttributesMaxApplicableFields {
|
||||
return model.NewAppError("hydratePropertyValues", "app.post.property_values.value_bound_exceeded.app_error",
|
||||
nil, "value count exceeds the per-page bound", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
byPost := make(map[string][]*model.PropertyValue, len(posts))
|
||||
for _, value := range values {
|
||||
// A value whose field is gone, or whose field is not a post field, is stale: the field
|
||||
// list is the authority on what applies.
|
||||
if _, ok := applicable[value.FieldID]; !ok {
|
||||
continue
|
||||
}
|
||||
byPost[value.TargetID] = append(byPost[value.TargetID], value)
|
||||
}
|
||||
|
||||
for _, post := range posts {
|
||||
postValues := byPost[post.Id]
|
||||
if len(postValues) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Field creation order, so a client renders attributes consistently across posts and
|
||||
// across requests. FieldID breaks ties for fields created in the same millisecond.
|
||||
slices.SortFunc(postValues, func(a, b *model.PropertyValue) int {
|
||||
if c := cmp.Compare(applicable[a.FieldID].CreateAt, applicable[b.FieldID].CreateAt); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(a.FieldID, b.FieldID)
|
||||
})
|
||||
|
||||
post.Metadata.PropertyValues = postValues
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// markPropertyValuesUnavailable tells the client the lookup failed, so it renders the post as
|
||||
// "attributes unknown" rather than as carrying none.
|
||||
func markPropertyValuesUnavailable(posts []*model.Post) {
|
||||
for _, post := range posts {
|
||||
if post == nil || post.Metadata == nil {
|
||||
continue
|
||||
}
|
||||
post.Metadata.PropertyValuesUnavailable = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type propertyValuesTestHelper struct {
|
||||
*TestHelper
|
||||
groupID string
|
||||
}
|
||||
|
||||
func setupPropertyValuesTest(t *testing.T) *propertyValuesTestHelper {
|
||||
t.Helper()
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
group, appErr := th.App.GetPropertyGroup(th.Context, model.PostAttributesPropertyGroupName)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
return &propertyValuesTestHelper{TestHelper: th, groupID: group.ID}
|
||||
}
|
||||
|
||||
// createField registers a post-object field at the given target. targetID is empty for system.
|
||||
func (th *propertyValuesTestHelper) createField(t *testing.T, targetType, targetID, name string) *model.PropertyField {
|
||||
t.Helper()
|
||||
field, appErr := th.App.CreatePropertyField(th.Context, &model.PropertyField{
|
||||
GroupID: th.groupID,
|
||||
Name: name,
|
||||
Type: model.PropertyFieldTypeText,
|
||||
ObjectType: model.PropertyFieldObjectTypePost,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
}, false, "")
|
||||
require.Nil(t, appErr)
|
||||
return field
|
||||
}
|
||||
|
||||
func (th *propertyValuesTestHelper) setValue(t *testing.T, post *model.Post, field *model.PropertyField, raw string) {
|
||||
t.Helper()
|
||||
_, appErr := th.App.UpsertPropertyValues(th.Context, []*model.PropertyValue{{
|
||||
TargetID: post.Id,
|
||||
TargetType: model.PropertyValueTargetTypePost,
|
||||
GroupID: th.groupID,
|
||||
FieldID: field.ID,
|
||||
Value: json.RawMessage(raw),
|
||||
CreatedBy: th.BasicUser.Id,
|
||||
UpdatedBy: th.BasicUser.Id,
|
||||
}}, model.PropertyValueTargetTypePost, post.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
}
|
||||
|
||||
func (th *propertyValuesTestHelper) post(t *testing.T, channel *model.Channel) *model.Post {
|
||||
t.Helper()
|
||||
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "post " + model.NewId(),
|
||||
}, channel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
return post
|
||||
}
|
||||
|
||||
// prepare re-fetches the post so metadata is built from scratch, as a real request would.
|
||||
func (th *propertyValuesTestHelper) prepare(t *testing.T, post *model.Post, groupID string) *model.Post {
|
||||
t.Helper()
|
||||
fresh, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
|
||||
require.Nil(t, appErr)
|
||||
fresh.Metadata = nil
|
||||
return th.App.PreparePostForClient(th.Context, fresh, &model.PreparePostForClientOpts{PropertyGroupID: groupID})
|
||||
}
|
||||
|
||||
func valueFieldIDs(post *model.Post) []string {
|
||||
ids := make([]string, 0, len(post.Metadata.PropertyValues))
|
||||
for _, v := range post.Metadata.PropertyValues {
|
||||
ids = append(ids, v.FieldID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestHydratePropertyValues(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
t.Run("attaches values only when a group is requested", func(t *testing.T) {
|
||||
th := setupPropertyValuesTest(t)
|
||||
field := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
|
||||
post := th.post(t, th.BasicChannel)
|
||||
th.setValue(t, post, field, `"confidential"`)
|
||||
|
||||
hydrated := th.prepare(t, post, th.groupID)
|
||||
require.Len(t, hydrated.Metadata.PropertyValues, 1)
|
||||
assert.Equal(t, field.ID, hydrated.Metadata.PropertyValues[0].FieldID)
|
||||
assert.JSONEq(t, `"confidential"`, string(hydrated.Metadata.PropertyValues[0].Value))
|
||||
|
||||
// Absent group means no lookup and no values, and crucially no unavailability marker:
|
||||
// the client must not read "not requested" as "failed to load".
|
||||
plain := th.prepare(t, post, "")
|
||||
assert.Empty(t, plain.Metadata.PropertyValues)
|
||||
assert.False(t, plain.Metadata.PropertyValuesUnavailable)
|
||||
})
|
||||
|
||||
t.Run("success with no values leaves the unavailable flag absent", func(t *testing.T) {
|
||||
th := setupPropertyValuesTest(t)
|
||||
th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
|
||||
post := th.post(t, th.BasicChannel)
|
||||
|
||||
hydrated := th.prepare(t, post, th.groupID)
|
||||
assert.Empty(t, hydrated.Metadata.PropertyValues)
|
||||
assert.False(t, hydrated.Metadata.PropertyValuesUnavailable)
|
||||
|
||||
// omitempty must keep both keys off the wire entirely.
|
||||
raw, err := json.Marshal(hydrated.Metadata)
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
assert.NotContains(t, decoded, "property_values")
|
||||
assert.NotContains(t, decoded, "property_values_unavailable")
|
||||
})
|
||||
|
||||
t.Run("applies system, team and channel fields but not another channel's", func(t *testing.T) {
|
||||
th := setupPropertyValuesTest(t)
|
||||
otherChannel := th.CreateChannel(t, th.BasicTeam)
|
||||
|
||||
systemField := th.createField(t, string(model.PropertyFieldTargetLevelSystem), "", "system-attr")
|
||||
teamField := th.createField(t, string(model.PropertyFieldTargetLevelTeam), th.BasicTeam.Id, "team-attr")
|
||||
channelField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "channel-attr")
|
||||
otherField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), otherChannel.Id, "other-attr")
|
||||
|
||||
post := th.post(t, th.BasicChannel)
|
||||
th.setValue(t, post, systemField, `"s"`)
|
||||
th.setValue(t, post, teamField, `"t"`)
|
||||
th.setValue(t, post, channelField, `"c"`)
|
||||
// A value for a field scoped to a different channel must be dropped, not returned.
|
||||
th.setValue(t, post, otherField, `"x"`)
|
||||
|
||||
hydrated := th.prepare(t, post, th.groupID)
|
||||
assert.ElementsMatch(t, []string{systemField.ID, teamField.ID, channelField.ID}, valueFieldIDs(hydrated))
|
||||
assert.NotContains(t, valueFieldIDs(hydrated), otherField.ID)
|
||||
})
|
||||
|
||||
t.Run("a DM has no team scope", func(t *testing.T) {
|
||||
th := setupPropertyValuesTest(t)
|
||||
dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
systemField := th.createField(t, string(model.PropertyFieldTargetLevelSystem), "", "system-attr")
|
||||
teamField := th.createField(t, string(model.PropertyFieldTargetLevelTeam), th.BasicTeam.Id, "team-attr")
|
||||
dmField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), dm.Id, "dm-attr")
|
||||
|
||||
post := th.post(t, dm)
|
||||
th.setValue(t, post, systemField, `"s"`)
|
||||
th.setValue(t, post, dmField, `"c"`)
|
||||
// No team, so a team-scoped value cannot apply.
|
||||
th.setValue(t, post, teamField, `"t"`)
|
||||
|
||||
hydrated := th.prepare(t, post, th.groupID)
|
||||
assert.ElementsMatch(t, []string{systemField.ID, dmField.ID}, valueFieldIDs(hydrated))
|
||||
})
|
||||
|
||||
t.Run("drops values whose field is deleted or is not a post field", func(t *testing.T) {
|
||||
th := setupPropertyValuesTest(t)
|
||||
liveField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "live")
|
||||
deletedField := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "doomed")
|
||||
|
||||
post := th.post(t, th.BasicChannel)
|
||||
th.setValue(t, post, liveField, `"live"`)
|
||||
th.setValue(t, post, deletedField, `"stale"`)
|
||||
|
||||
require.Nil(t, th.App.DeletePropertyField(th.Context, th.groupID, deletedField.ID, false, ""))
|
||||
|
||||
hydrated := th.prepare(t, post, th.groupID)
|
||||
assert.Equal(t, []string{liveField.ID}, valueFieldIDs(hydrated))
|
||||
})
|
||||
|
||||
t.Run("orders values by field creation time", func(t *testing.T) {
|
||||
th := setupPropertyValuesTest(t)
|
||||
// Names are deliberately reverse-alphabetical relative to creation order.
|
||||
first := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "zulu")
|
||||
second := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "alpha")
|
||||
|
||||
post := th.post(t, th.BasicChannel)
|
||||
// Values written in the opposite order to prove output order tracks the field, not the value.
|
||||
th.setValue(t, post, second, `"2"`)
|
||||
th.setValue(t, post, first, `"1"`)
|
||||
|
||||
hydrated := th.prepare(t, post, th.groupID)
|
||||
require.Len(t, hydrated.Metadata.PropertyValues, 2)
|
||||
assert.Equal(t, []string{first.ID, second.ID}, valueFieldIDs(hydrated))
|
||||
})
|
||||
|
||||
t.Run("a deleted post is never hydrated", func(t *testing.T) {
|
||||
th := setupPropertyValuesTest(t)
|
||||
field := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
|
||||
post := th.post(t, th.BasicChannel)
|
||||
th.setValue(t, post, field, `"confidential"`)
|
||||
|
||||
_, appErr := th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
fresh, appErr := th.App.GetSinglePost(th.Context, post.Id, true)
|
||||
require.Nil(t, appErr)
|
||||
fresh.Metadata = nil
|
||||
hydrated := th.App.PreparePostForClient(th.Context, fresh,
|
||||
&model.PreparePostForClientOpts{PropertyGroupID: th.groupID, IncludeDeleted: true})
|
||||
|
||||
assert.Empty(t, hydrated.Metadata.PropertyValues)
|
||||
assert.False(t, hydrated.Metadata.PropertyValuesUnavailable)
|
||||
})
|
||||
|
||||
t.Run("values survive sanitization", func(t *testing.T) {
|
||||
th := setupPropertyValuesTest(t)
|
||||
field := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
|
||||
post := th.post(t, th.BasicChannel)
|
||||
th.setValue(t, post, field, `"confidential"`)
|
||||
|
||||
hydrated := th.prepare(t, post, th.groupID)
|
||||
require.Len(t, hydrated.Metadata.PropertyValues, 1)
|
||||
|
||||
sanitized, _, appErr := th.App.SanitizePostMetadataForUser(th.Context, hydrated, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, sanitized.Metadata)
|
||||
assert.Len(t, sanitized.Metadata.PropertyValues, 1)
|
||||
})
|
||||
// An unrevealed burn-on-read post has its metadata blanked for non-authors. Unlike the deleted-post
|
||||
// case, that blanking does not return early, so hydration running afterwards would re-attach
|
||||
// attribute values to a post whose content is being withheld. The author, who is allowed to see the
|
||||
// post, still gets them.
|
||||
t.Run("an unrevealed burn-on-read post", func(t *testing.T) {
|
||||
th := setupPropertyValuesTest(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.ServiceSettings.EnableBurnOnRead = new(true)
|
||||
})
|
||||
|
||||
field := th.createField(t, string(model.PropertyFieldTargetLevelChannel), th.BasicChannel.Id, "sensitivity")
|
||||
|
||||
borPost := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn after reading",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*60*1000))
|
||||
post, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
th.setValue(t, post, field, `"confidential"`)
|
||||
|
||||
viewer := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, viewer, th.BasicTeam)
|
||||
th.AddUserToChannel(t, viewer, th.BasicChannel)
|
||||
|
||||
prepareAs := func(t *testing.T, userID string) *model.Post {
|
||||
t.Helper()
|
||||
original := th.Context.Session().UserId
|
||||
th.Context.Session().UserId = userID
|
||||
t.Cleanup(func() { th.Context.Session().UserId = original })
|
||||
|
||||
fresh, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
|
||||
require.Nil(t, appErr)
|
||||
fresh.Metadata = nil
|
||||
return th.App.PreparePostForClient(th.Context, fresh,
|
||||
&model.PreparePostForClientOpts{PropertyGroupID: th.groupID})
|
||||
}
|
||||
|
||||
t.Run("a non-author gets no values while the post is unrevealed", func(t *testing.T) {
|
||||
hydrated := prepareAs(t, viewer.Id)
|
||||
require.NotNil(t, hydrated.Metadata)
|
||||
assert.Empty(t, hydrated.Metadata.PropertyValues)
|
||||
// Not an unavailability either: the values were withheld, not unloadable.
|
||||
assert.False(t, hydrated.Metadata.PropertyValuesUnavailable)
|
||||
})
|
||||
|
||||
t.Run("the author gets values in the same conditions", func(t *testing.T) {
|
||||
hydrated := prepareAs(t, th.BasicUser.Id)
|
||||
require.NotNil(t, hydrated.Metadata)
|
||||
require.Len(t, hydrated.Metadata.PropertyValues, 1)
|
||||
assert.Equal(t, field.ID, hydrated.Metadata.PropertyValues[0].FieldID)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -3174,6 +3174,10 @@
|
||||
"id": "api.post.posts_by_ids.invalid_body.request_error",
|
||||
"translation": "The number of Post IDs received has exceeded the maximum size of {{.MaxLength}}"
|
||||
},
|
||||
{
|
||||
"id": "api.post.property_groups.too_many.app_error",
|
||||
"translation": "Only one property group can be requested at a time."
|
||||
},
|
||||
{
|
||||
"id": "api.post.reveal_post.cannot_reveal_own_post.app_error",
|
||||
"translation": "You cannot reveal your own burn-on-read post."
|
||||
@@ -8432,6 +8436,14 @@
|
||||
"id": "app.post.permanent_delete_post.error",
|
||||
"translation": "Failed to permanently delete post."
|
||||
},
|
||||
{
|
||||
"id": "app.post.property_values.field_bound_exceeded.app_error",
|
||||
"translation": "The number of attributes that apply to this post exceeds the supported limit."
|
||||
},
|
||||
{
|
||||
"id": "app.post.property_values.value_bound_exceeded.app_error",
|
||||
"translation": "The number of attribute values for these posts exceeds the supported limit."
|
||||
},
|
||||
{
|
||||
"id": "app.post.restore_post_version.get_single.app_error",
|
||||
"translation": "Failed to get the old post version."
|
||||
|
||||
@@ -3622,6 +3622,23 @@ func (c *Client4) GetPostIncludeDeleted(ctx context.Context, postId string, etag
|
||||
return DecodeJSONFromResponse[*Post](r)
|
||||
}
|
||||
|
||||
// GetPostWithOptions gets a single post, applying every option the endpoint supports.
|
||||
func (c *Client4) GetPostWithOptions(ctx context.Context, postId string, etag string, opts GetPostOptions) (*Post, *Response, error) {
|
||||
values := url.Values{}
|
||||
if opts.IncludeDeleted {
|
||||
values.Set("include_deleted", c.boolString(true))
|
||||
}
|
||||
if len(opts.IncludePropertyGroups) > 0 {
|
||||
values.Set("include_property_groups", strings.Join(opts.IncludePropertyGroups, ","))
|
||||
}
|
||||
r, err := c.doAPIGetWithQuery(ctx, c.postRoute(postId), values, etag)
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
return DecodeJSONFromResponse[*Post](r)
|
||||
}
|
||||
|
||||
// DeletePost deletes a post from the provided post id string.
|
||||
func (c *Client4) DeletePost(ctx context.Context, postId string) (*Response, error) {
|
||||
r, err := c.doAPIDelete(ctx, c.postRoute(postId))
|
||||
|
||||
@@ -453,6 +453,17 @@ type GetPostsSinceForSyncOptions struct {
|
||||
ExcludedPostTypes []string // post types to exclude from sync
|
||||
}
|
||||
|
||||
// GetPostOptions are the options for fetching a single post. Its plural sibling
|
||||
// GetPostsOptions covers the list endpoints.
|
||||
type GetPostOptions struct {
|
||||
// IncludeDeleted returns the post even if it is soft-deleted.
|
||||
IncludeDeleted bool
|
||||
|
||||
// IncludePropertyGroups names the property groups whose values should be hydrated onto the
|
||||
// post's metadata.
|
||||
IncludePropertyGroups []string
|
||||
}
|
||||
|
||||
type GetPostsOptions struct {
|
||||
UserId string
|
||||
ChannelId string
|
||||
|
||||
Reference in New Issue
Block a user