* create ChannelBookmarks table

* ChannelBookmark model

* channel bookamrks Store layer

* add GetBookmarksForAllChannelByIdSince

* add channel bookmarks to test store

* Add channel bookmarks to app layer

* remove index for createAt in channel bookmarks migrations

* remove createAt from select channel bookmark query and enable store delete bookmark test

* update reponse of UpdateBookmark

* rename db migration files

* channel bookmarks store update sort order

* channel bookmarks app layer update sort order

* fix lint & tests

* Fix lint and introduce util functions to insert / remove from slice

* remove model etag

* i18n

* defer remove file info after test run

* Fix tests passing the request context

* fix migrations

* fix TestRetry

* Add bookmark permissions (#25560)

* Adds channel bookmarks permissions

* Fix linter

* Remove unnecessary empty lines

* Remove scss change as it's not necessary anymore

* Fix mock store

* Fix mock store and add role entry

* Fix test

* Adds cypress test and update permissions migration to update admin roles

* Adds channel bookmarks roles to default admin roles

* Adds bookmark permissions to default role permissions constant in webapp

* Update mmctl test

* Update permission test after normalising the roles

* fix store tests

* fix app layer tests

* Add new bookmark endpoint (#25624)

* Adds channel bookmarks api scaffold and create endpoint

* Applies review comments to the API docs

* Adds websocket test to create channel bookmark

---------

Co-authored-by: Mattermost Build <build@mattermost.com>

* MM-54426 exclude Channel Bookmarks files from data retention (#25656)

* Augment channel APIs to include bookmarks (#25567)

* update files docs for server 9.4

* Adds update channel bookmark endpoint (#25653)

* Adds update channel bookmark sort order endpoint (#25686)

* Adds update channel bookmark endpoint

* Updates edit app method to return the right deleted bookmark and adds tests

* Adds the update channel bookmark sort order endpoint

* Fix repeated test after merge

* Assign right permissions to each test

* Update store and app layer to return specific errors and add tests

* Adds delete channel bookmark endpoint (#25693)

* Updates edit app method to return the right deleted bookmark and adds tests

* Fix repeated test after merge

* Updates edit app method to return the right deleted bookmark and adds tests

* Adds delete channel bookmark endpoint

* Adds list channel bookmarks endpoint (#25700)

* Add channel moderation to bookmarks (#25716)

* fix migrations index

* fix getChannelsForTeamForUser

* fix getChannelsForTeamForUser

* fix bad merge client4

* fix file api with bookmark permission

* add ChannelBookmarks feature flag

* add missing translations

* Set DB column for type as enum

* use custom type for bookmark query using sqlx

* use transaction when saving bookmark

* return NewErrNotFound instead of Sql.ErrNoRows

* use squirrel for IN query

* add a limit of 1K for records in GetBookmarksForAllChannelByIdSince

* UpdateSortOrder with one single query instead of multiple updates

* fix shadow declaration

* fix channel bookmarks permission string definition in admin console

* fix another shadow declaration

* Fix model conversion

* add SplitSliceInChunks

* remove include bookmarks in channels api

* Cap amount of bookmarks per channel

* add etag back to get channels

* feedback review

* update file info when replacing a bookmark file

* return 501 not implemented when the license is not available

* add detail message when getting channel member on bookmark api

* start audit before permission check on create bookmark api

* use require.Eventuallyf for testing WS events

* remove unnecessary log in app layer

* use require instead of assert to avoid panics

* enforce limit when querying bookmarks since

* prevent to create/update bookmark if file is already attached

* fix lint

* delete file when a bookmark is deleted

* Dot allow to set a fileId and a url at the same time to a bookmark

* fix query to delete a file that belongs to a bookmark

* do not patch the bookmark type

* Server side FeatureFlag check (#26145)

* use ff in server, set ff to false

* turn on FF for unit tests

* defer unset FF for unit tests

* turn ff on for testing

* only allow attaching files that were uploaded for bookmark

* Set feature flag off as default

* fix lint

* update email templates as PR failed

* revert templates

* force the assignment of ID when creating a bookmark

* Fix unit tests

---------

Co-authored-by: Miguel de la Cruz <miguel@mcrx.me>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Caleb Roseland <caleb@calebroseland.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Этот коммит содержится в:
Elias Nahum
2024-03-12 22:36:05 +08:00
коммит произвёл GitHub
родитель 2480a6c646
Коммит 7e9cd04a8b
66 изменённых файлов: 6578 добавлений и 57 удалений

322
server/public/model/channel_bookmark.go Обычный файл
Просмотреть файл

@@ -0,0 +1,322 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
)
type ChannelBookmarkType string
const (
ChannelBookmarkLink ChannelBookmarkType = "link"
ChannelBookmarkFile ChannelBookmarkType = "file"
BookmarkFileOwner = "bookmark"
MaxBookmarksPerChannel = 50
)
type ChannelBookmark struct {
Id string `json:"id"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
ChannelId string `json:"channel_id"`
OwnerId string `json:"owner_id"`
FileId string `json:"file_id"`
DisplayName string `json:"display_name"`
SortOrder int64 `json:"sort_order"`
LinkUrl string `json:"link_url,omitempty"`
ImageUrl string `json:"image_url,omitempty"`
Emoji string `json:"emoji,omitempty"`
Type ChannelBookmarkType `json:"type"`
OriginalId string `json:"original_id,omitempty"`
ParentId string `json:"parent_id,omitempty"`
}
func (o *ChannelBookmark) Auditable() map[string]interface{} {
return map[string]interface{}{
"id": o.Id,
"create_at": o.CreateAt,
"update_at": o.UpdateAt,
"delete_at": o.DeleteAt,
"channel_id": o.ChannelId,
"owner_id": o.OwnerId,
"file_id": o.FileId,
"type": o.Type,
"original_id": o.OriginalId,
"parent_id": o.ParentId,
}
}
// Clone returns a shallow copy of the channel bookmark.
func (o *ChannelBookmark) Clone() *ChannelBookmark {
bCopy := *o
return &bCopy
}
// SetOriginal generates a new bookmark copying the data of the
// receiver bookmark, resets its timestamps and main ID, updates its
// OriginalId and sets the owner to the ID passed as a parameter
func (o *ChannelBookmark) SetOriginal(newOwnerId string) *ChannelBookmark {
bCopy := *o
bCopy.Id = ""
bCopy.CreateAt = 0
bCopy.DeleteAt = 0
bCopy.UpdateAt = 0
bCopy.OriginalId = o.Id
bCopy.OwnerId = newOwnerId
return &bCopy
}
func (o *ChannelBookmark) IsValid() *AppError {
if !IsValidId(o.Id) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if o.CreateAt == 0 {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.create_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.UpdateAt == 0 {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.update_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if !IsValidId(o.ChannelId) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(o.OwnerId) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.owner_id.app_error", nil, "", http.StatusBadRequest)
}
if o.DisplayName == "" {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.display_name.app_error", nil, "", http.StatusBadRequest)
}
if !(o.Type == ChannelBookmarkFile || o.Type == ChannelBookmarkLink) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.type.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.Type == ChannelBookmarkLink && (o.LinkUrl == "" || !IsValidHTTPURL(o.LinkUrl)) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.link_url.missing_or_invalid.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.Type == ChannelBookmarkLink && o.ImageUrl != "" && !IsValidHTTPURL(o.ImageUrl) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.image_url.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.Type == ChannelBookmarkFile && (o.FileId == "" || !IsValidId(o.FileId)) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.file_id.missing_or_invalid.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.ImageUrl != "" && o.FileId != "" {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.link_file.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.OriginalId != "" && !IsValidId(o.OriginalId) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.original_id.app_error", nil, "", http.StatusBadRequest)
}
if o.ParentId != "" && !IsValidId(o.ParentId) {
return NewAppError("ChannelBookmark.IsValid", "model.channel_bookmark.is_valid.parent_id.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
func (o *ChannelBookmark) PreSave() {
if o.Id == "" {
o.Id = NewId()
}
o.DisplayName = SanitizeUnicode(o.DisplayName)
if o.CreateAt == 0 {
o.CreateAt = GetMillis()
}
o.UpdateAt = o.CreateAt
}
func (o *ChannelBookmark) PreUpdate() {
o.UpdateAt = GetMillis()
o.DisplayName = SanitizeUnicode(o.DisplayName)
}
func (o *ChannelBookmark) ToBookmarkWithFileInfo(f *FileInfo) *ChannelBookmarkWithFileInfo {
bwf := ChannelBookmarkWithFileInfo{
ChannelBookmark: &ChannelBookmark{
Id: o.Id,
CreateAt: o.CreateAt,
UpdateAt: o.UpdateAt,
DeleteAt: o.DeleteAt,
ChannelId: o.ChannelId,
OwnerId: o.OwnerId,
FileId: o.FileId,
DisplayName: o.DisplayName,
SortOrder: o.SortOrder,
LinkUrl: o.LinkUrl,
ImageUrl: o.ImageUrl,
Emoji: o.Emoji,
Type: o.Type,
OriginalId: o.OriginalId,
ParentId: o.ParentId,
},
}
if f != nil && f.Id != "" {
bwf.FileInfo = f
}
return &bwf
}
type ChannelBookmarkPatch struct {
FileId *string `json:"file_id"`
DisplayName *string `json:"display_name"`
SortOrder *int64 `json:"sort_order"`
LinkUrl *string `json:"link_url,omitempty"`
ImageUrl *string `json:"image_url,omitempty"`
Emoji *string `json:"emoji,omitempty"`
}
func (o *ChannelBookmarkPatch) Auditable() map[string]interface{} {
return map[string]interface{}{
"file_id": o.FileId,
}
}
func (o *ChannelBookmark) Patch(patch *ChannelBookmarkPatch) {
if patch.FileId != nil {
o.FileId = *patch.FileId
}
if patch.DisplayName != nil {
o.DisplayName = *patch.DisplayName
}
if patch.SortOrder != nil {
o.SortOrder = *patch.SortOrder
}
if patch.LinkUrl != nil {
o.LinkUrl = *patch.LinkUrl
}
if patch.ImageUrl != nil {
o.ImageUrl = *patch.ImageUrl
}
if patch.Emoji != nil {
o.Emoji = *patch.Emoji
}
}
type ChannelBookmarkWithFileInfo struct {
*ChannelBookmark
FileInfo *FileInfo `json:"file,omitempty"`
}
func (o *ChannelBookmarkWithFileInfo) Auditable() map[string]interface{} {
a := o.ChannelBookmark.Auditable()
if o.FileInfo != nil {
a["file"] = o.FileInfo.Auditable()
}
return a
}
// Clone returns a shallow copy of the channel bookmark with file info.
func (o *ChannelBookmarkWithFileInfo) Clone() *ChannelBookmarkWithFileInfo {
bCopy := *o
return &bCopy
}
type ChannelWithBookmarks struct {
*Channel
Bookmarks []*ChannelBookmarkWithFileInfo `json:"bookmarks,omitempty"`
}
type ChannelWithTeamDataAndBookmarks struct {
*ChannelWithTeamData
Bookmarks []*ChannelBookmarkWithFileInfo `json:"bookmarks,omitempty"`
}
type UpdateChannelBookmarkResponse struct {
Updated *ChannelBookmarkWithFileInfo `json:"updated,omitempty"`
Deleted *ChannelBookmarkWithFileInfo `json:"deleted,omitempty"`
}
func (o *UpdateChannelBookmarkResponse) Auditable() map[string]any {
a := map[string]any{}
if o.Updated != nil {
a["updated"] = o.Updated.Auditable()
}
if o.Deleted != nil {
a["updated"] = o.Deleted.Auditable()
}
return a
}
type ChannelBookmarkAndFileInfo struct {
Id string
CreateAt int64
UpdateAt int64
DeleteAt int64
ChannelId string
OwnerId string
FileInfoId string
DisplayName string
SortOrder int64
LinkUrl string
ImageUrl string
Emoji string
Type ChannelBookmarkType
OriginalId string
ParentId string
FileId string
FileName string
Extension string
Size int64
MimeType string
Width int
Height int
HasPreviewImage bool
MiniPreview *[]byte
}
func (o *ChannelBookmarkAndFileInfo) ToChannelBookmarkWithFileInfo() *ChannelBookmarkWithFileInfo {
bwf := &ChannelBookmarkWithFileInfo{
ChannelBookmark: &ChannelBookmark{
Id: o.Id,
CreateAt: o.CreateAt,
UpdateAt: o.UpdateAt,
DeleteAt: o.DeleteAt,
ChannelId: o.ChannelId,
OwnerId: o.OwnerId,
FileId: o.FileInfoId,
DisplayName: o.DisplayName,
SortOrder: o.SortOrder,
LinkUrl: o.LinkUrl,
ImageUrl: o.ImageUrl,
Emoji: o.Emoji,
Type: o.Type,
OriginalId: o.OriginalId,
ParentId: o.ParentId,
},
}
if o.FileInfoId != "" && o.FileId != "" {
miniPreview := o.MiniPreview
if len(*miniPreview) == 0 {
miniPreview = nil
}
bwf.FileInfo = &FileInfo{
Id: o.FileId,
Name: o.FileName,
Extension: o.Extension,
Size: o.Size,
MimeType: o.MimeType,
Width: o.Width,
Height: o.Height,
HasPreviewImage: o.HasPreviewImage,
MiniPreview: miniPreview,
}
}
return bwf
}

544
server/public/model/channel_bookmark_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,544 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChannelBookmarkIsValid(t *testing.T) {
testCases := []struct {
Description string
Bookmark *ChannelBookmark
ExpectedIsValid bool
}{
{
"nil bookmark",
&ChannelBookmark{},
false,
},
{
"bookmark without create at timestamp",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 0,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark without update at timestamp",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 0,
DeleteAt: 4,
},
false,
},
{
"bookmark with missing channel id",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with invalid channel id",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "invalid",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with missing owner id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: "",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with invalid user id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: "invalid",
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with missing displayname",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with missing type",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: "",
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark with invalid type",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: "invalid",
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type link with missing link url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type link with invalid link url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "invalid",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type link with valid link url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
true,
},
{
"bookmark of type link with empty image url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
true,
},
{
"bookmark of type link with invalid image url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
ImageUrl: "invalid",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type link with invalid image url",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
ImageUrl: "https://mattermost.com/some-image-without-extension", // we don't care if the URL is an actual image as the client should handle the error
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
true,
},
{
"bookmark of type file with missing file id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type file with invalid file id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: "invalid",
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
false,
},
{
"bookmark of type file with valid file id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
},
true,
},
{
"bookmark of type file with invalid original id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
OriginalId: "invalid",
},
false,
},
{
"bookmark of type file with invalid parent id",
&ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 2,
UpdateAt: 3,
DeleteAt: 4,
ParentId: "invalid",
},
false,
},
{
"bookmark of type link with a file Id attached",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "",
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "http://somelink",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkLink,
CreateAt: 0,
UpdateAt: 3,
DeleteAt: 0,
},
false,
},
{
"bookmark of type file with a url",
&ChannelBookmark{
Id: NewId(),
OwnerId: NewId(),
ChannelId: "",
FileId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "http://somelink",
ImageUrl: "",
Emoji: "",
Type: ChannelBookmarkFile,
CreateAt: 0,
UpdateAt: 3,
DeleteAt: 0,
},
false,
},
}
for _, testCase := range testCases {
t.Run(testCase.Description, func(t *testing.T) {
if testCase.ExpectedIsValid {
require.Nil(t, testCase.Bookmark.IsValid())
} else {
require.NotNil(t, testCase.Bookmark.IsValid())
}
})
}
}
func TestChannelBookmarkPreSave(t *testing.T) {
bookmark := &ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
Type: ChannelBookmarkLink,
DeleteAt: 0,
}
originalBookmark := &ChannelBookmark{
Id: bookmark.Id,
ChannelId: bookmark.ChannelId,
OwnerId: bookmark.OwnerId,
DisplayName: bookmark.DisplayName,
SortOrder: bookmark.SortOrder,
LinkUrl: bookmark.LinkUrl,
Type: bookmark.Type,
DeleteAt: bookmark.DeleteAt,
}
bookmark.PreSave()
assert.NotEqual(t, 0, bookmark.CreateAt)
assert.NotEqual(t, 0, bookmark.UpdateAt)
originalBookmark.CreateAt = bookmark.CreateAt
originalBookmark.UpdateAt = bookmark.UpdateAt
assert.Equal(t, originalBookmark, bookmark)
}
func TestChannelBookmarkPreUpdate(t *testing.T) {
bookmark := &ChannelBookmark{
Id: NewId(),
ChannelId: NewId(),
OwnerId: NewId(),
DisplayName: "display name",
SortOrder: 0,
LinkUrl: "https://mattermost.com",
Type: ChannelBookmarkLink,
CreateAt: 2,
DeleteAt: 0,
}
originalBookmark := &ChannelBookmark{
Id: bookmark.Id,
ChannelId: bookmark.ChannelId,
OwnerId: bookmark.OwnerId,
DisplayName: bookmark.DisplayName,
SortOrder: bookmark.SortOrder,
LinkUrl: bookmark.LinkUrl,
Type: bookmark.Type,
DeleteAt: bookmark.DeleteAt,
}
bookmark.PreSave()
assert.NotEqual(t, 0, bookmark.UpdateAt)
originalBookmark.CreateAt = bookmark.CreateAt
originalBookmark.UpdateAt = bookmark.UpdateAt
assert.Equal(t, originalBookmark, bookmark)
bookmark.PreUpdate()
assert.Greater(t, bookmark.UpdateAt, originalBookmark.UpdateAt)
}
func TestChannelBookmarkPatch(t *testing.T) {
p := &ChannelBookmarkPatch{
DisplayName: NewString(NewId()),
SortOrder: NewInt64(1),
LinkUrl: NewString(NewId()),
}
b := ChannelBookmark{
Id: NewId(),
DisplayName: NewId(),
Type: ChannelBookmarkLink, // should not update
LinkUrl: NewId(),
}
b.Patch(p)
require.Empty(t, b.FileId)
require.Equal(t, *p.DisplayName, b.DisplayName)
require.Equal(t, *p.SortOrder, b.SortOrder)
require.Equal(t, *p.LinkUrl, b.LinkUrl)
require.Equal(t, ChannelBookmarkLink, b.Type)
}

Просмотреть файл

@@ -588,6 +588,14 @@ func (c *Client4) limitsRoute() string {
return "/limits"
}
func (c *Client4) bookmarksRoute(channelId string) string {
return c.channelRoute(channelId) + "/bookmarks"
}
func (c *Client4) bookmarkRoute(channelId, bookmarkId string) string {
return fmt.Sprintf(c.bookmarksRoute(channelId)+"/%v", bookmarkId)
}
func (c *Client4) DoAPIGet(ctx context.Context, url string, etag string) (*http.Response, error) {
return c.DoAPIRequest(ctx, http.MethodGet, c.APIURL+url, "", etag)
}
@@ -8917,3 +8925,85 @@ func (c *Client4) GetUserLimits(ctx context.Context) (*UserLimits, *Response, er
}
return &userLimits, BuildResponse(r), nil
}
// CreateChannelBookmark creates a channel bookmark based on the provided struct.
func (c *Client4) CreateChannelBookmark(ctx context.Context, channelBookmark *ChannelBookmark) (*ChannelBookmark, *Response, error) {
channelBookmarkJSON, err := json.Marshal(channelBookmark)
if err != nil {
return nil, nil, NewAppError("CreateChannelBookmark", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
r, err := c.DoAPIPostBytes(ctx, c.bookmarksRoute(channelBookmark.ChannelId), channelBookmarkJSON)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var cb ChannelBookmark
if err := json.NewDecoder(r.Body).Decode(&cb); err != nil {
return nil, nil, NewAppError("CreateChannelBookmark", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &cb, BuildResponse(r), nil
}
// UpdateChannelBookmark updates a channel bookmark based on the provided struct.
func (c *Client4) UpdateChannelBookmark(ctx context.Context, channelId, bookmarkId string, patch *ChannelBookmarkPatch) (*UpdateChannelBookmarkResponse, *Response, error) {
buf, err := json.Marshal(patch)
if err != nil {
return nil, nil, NewAppError("UpdateChannelBookmark", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
r, err := c.DoAPIPatchBytes(ctx, c.bookmarkRoute(channelId, bookmarkId), buf)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var ucb UpdateChannelBookmarkResponse
if err := json.NewDecoder(r.Body).Decode(&ucb); err != nil {
return nil, nil, NewAppError("UpdateChannelBookmark", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &ucb, BuildResponse(r), nil
}
// UpdateChannelBookmarkSortOrder updates a channel bookmark's sort order based on the provided new index.
func (c *Client4) UpdateChannelBookmarkSortOrder(ctx context.Context, channelId, bookmarkId string, sortOrder int64) ([]*ChannelBookmarkWithFileInfo, *Response, error) {
buf, err := json.Marshal(sortOrder)
if err != nil {
return nil, nil, NewAppError("UpdateChannelBookmarkSortOrder", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
r, err := c.DoAPIPostBytes(ctx, c.bookmarkRoute(channelId, bookmarkId)+"/sort_order", buf)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var b []*ChannelBookmarkWithFileInfo
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
return nil, nil, NewAppError("UpdateChannelBookmarkSortOrder", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return b, BuildResponse(r), nil
}
// DeleteChannelBookmark deletes a channel bookmark.
func (c *Client4) DeleteChannelBookmark(ctx context.Context, channelId, bookmarkId string) (*ChannelBookmarkWithFileInfo, *Response, error) {
r, err := c.DoAPIDelete(ctx, c.bookmarkRoute(channelId, bookmarkId))
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var b *ChannelBookmarkWithFileInfo
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
return nil, nil, NewAppError("DeleteChannelBookmark", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return b, BuildResponse(r), nil
}
func (c *Client4) ListChannelBookmarksForChannel(ctx context.Context, channelId string, since int64) ([]*ChannelBookmarkWithFileInfo, *Response, error) {
query := fmt.Sprintf("?bookmarks_since=%v", since)
r, err := c.DoAPIGet(ctx, c.bookmarksRoute(channelId)+query, "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var b []*ChannelBookmarkWithFileInfo
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
return nil, nil, NewAppError("ListChannelBookmarksForChannel", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return b, BuildResponse(r), nil
}

Просмотреть файл

@@ -49,10 +49,11 @@ type FeatureFlags struct {
CloudIPFiltering bool
ConsumePostHook bool
CloudAnnualRenewals bool
CloudAnnualRenewals bool
CloudDedicatedExportUI bool
ChannelBookmarks bool
WebSocketEventScope bool
}
@@ -74,6 +75,7 @@ func (f *FeatureFlags) SetDefaults() {
f.ConsumePostHook = false
f.CloudAnnualRenewals = false
f.CloudDedicatedExportUI = false
f.ChannelBookmarks = false
f.WebSocketEventScope = false
}

Просмотреть файл

@@ -97,7 +97,7 @@ func (fi *FileInfo) IsValid() *AppError {
return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(fi.CreatorId) && fi.CreatorId != "nouser" {
if !IsValidId(fi.CreatorId) && (fi.CreatorId != "nouser" && fi.CreatorId != BookmarkFileOwner) {
return NewAppError("FileInfo.IsValid", "model.file_info.is_valid.user_id.app_error", nil, "id="+fi.Id, http.StatusBadRequest)
}

Просмотреть файл

@@ -53,6 +53,13 @@ func TestFileInfoIsValid(t *testing.T) {
assert.NotNil(t, info.IsValid(), "empty Path isn't valid")
info.Path = "fake/path.png"
})
t.Run("Creator ID for bookmarks is valid", func(t *testing.T) {
creatorId := info.CreatorId
info.CreatorId = BookmarkFileOwner
assert.Nil(t, info.IsValid(), "creatorId isn't valid")
info.CreatorId = creatorId
})
}
func TestFileInfoIsImage(t *testing.T) {

Просмотреть файл

@@ -47,4 +47,5 @@ const (
MigrationKeyDeleteOrphanDrafts = "delete_orphan_drafts_migration"
MigrationKeyAddIPFilteringPermissions = "add_ip_filtering_permissions"
MigrationKeyAddOutgoingOAuthConnectionsPermissions = "add_outgoing_oauth_connections_permissions"
MigrationKeyAddChannelBookmarksPermissions = "add_channel_bookmarks_permissions"
)

Просмотреть файл

@@ -109,6 +109,14 @@ var PermissionPromoteGuest *Permission
var PermissionDemoteToGuest *Permission
var PermissionUseChannelMentions *Permission
var PermissionUseGroupMentions *Permission
var PermissionAddBookmarkPublicChannel *Permission
var PermissionEditBookmarkPublicChannel *Permission
var PermissionDeleteBookmarkPublicChannel *Permission
var PermissionOrderBookmarkPublicChannel *Permission
var PermissionAddBookmarkPrivateChannel *Permission
var PermissionEditBookmarkPrivateChannel *Permission
var PermissionDeleteBookmarkPrivateChannel *Permission
var PermissionOrderBookmarkPrivateChannel *Permission
var PermissionReadOtherUsersTeams *Permission
var PermissionEditBrand *Permission
var PermissionManageSharedChannels *Permission
@@ -389,6 +397,7 @@ var SysconsoleReadPermissions []*Permission
var SysconsoleWritePermissions []*Permission
var PermissionManageOutgoingOAuthConnections *Permission
var ModeratedBookmarkPermissions []*Permission
func initializePermissions() {
PermissionInviteUser = &Permission{
@@ -1177,6 +1186,57 @@ func initializePermissions() {
"authentication.permissions.use_group_mentions.description",
PermissionScopeChannel,
}
// Channel bookmarks
PermissionAddBookmarkPublicChannel = &Permission{
"add_bookmark_public_channel",
"",
"",
PermissionScopeChannel,
}
PermissionEditBookmarkPublicChannel = &Permission{
"edit_bookmark_public_channel",
"",
"",
PermissionScopeChannel,
}
PermissionDeleteBookmarkPublicChannel = &Permission{
"delete_bookmark_public_channel",
"",
"",
PermissionScopeChannel,
}
PermissionOrderBookmarkPublicChannel = &Permission{
"order_bookmark_public_channel",
"",
"",
PermissionScopeChannel,
}
PermissionAddBookmarkPrivateChannel = &Permission{
"add_bookmark_private_channel",
"",
"",
PermissionScopeChannel,
}
PermissionEditBookmarkPrivateChannel = &Permission{
"edit_bookmark_private_channel",
"",
"",
PermissionScopeChannel,
}
PermissionDeleteBookmarkPrivateChannel = &Permission{
"delete_bookmark_private_channel",
"",
"",
PermissionScopeChannel,
}
PermissionOrderBookmarkPrivateChannel = &Permission{
"order_bookmark_private_channel",
"",
"",
PermissionScopeChannel,
}
PermissionReadOtherUsersTeams = &Permission{
"read_other_users_teams",
"authentication.permissions.read_other_users_teams.name",
@@ -2386,6 +2446,14 @@ func initializePermissions() {
PermissionDeleteOthersPosts,
PermissionUseChannelMentions,
PermissionUseGroupMentions,
PermissionAddBookmarkPublicChannel,
PermissionEditBookmarkPublicChannel,
PermissionDeleteBookmarkPublicChannel,
PermissionOrderBookmarkPublicChannel,
PermissionAddBookmarkPrivateChannel,
PermissionEditBookmarkPrivateChannel,
PermissionDeleteBookmarkPrivateChannel,
PermissionOrderBookmarkPrivateChannel,
}
GroupScopedPermissions := []*Permission{
@@ -2454,6 +2522,7 @@ func initializePermissions() {
"create_reactions",
"manage_members",
PermissionUseChannelMentions.Id,
"manage_bookmarks",
}
ChannelModeratedPermissionsMap = map[string]string{
@@ -2464,6 +2533,21 @@ func initializePermissions() {
PermissionManagePrivateChannelMembers.Id: ChannelModeratedPermissions[2],
PermissionUseChannelMentions.Id: ChannelModeratedPermissions[3],
}
ModeratedBookmarkPermissions = []*Permission{
PermissionAddBookmarkPublicChannel,
PermissionEditBookmarkPublicChannel,
PermissionDeleteBookmarkPublicChannel,
PermissionOrderBookmarkPublicChannel,
PermissionAddBookmarkPrivateChannel,
PermissionEditBookmarkPrivateChannel,
PermissionDeleteBookmarkPrivateChannel,
PermissionOrderBookmarkPrivateChannel,
}
for _, mbp := range ModeratedBookmarkPermissions {
ChannelModeratedPermissionsMap[mbp.Id] = ChannelModeratedPermissions[4]
}
}
func init() {

Просмотреть файл

@@ -582,6 +582,15 @@ func ChannelModeratedPermissionsChangedByPatch(role *Role, patch *RolePatch) []s
return result
}
func isModeratedBookmarkPermission(permission string) bool {
for _, mbp := range ModeratedBookmarkPermissions {
if mbp.Id == permission {
return true
}
}
return false
}
// GetChannelModeratedPermissions returns a map of channel moderated permissions that the role has access to
func (r *Role) GetChannelModeratedPermissions(channelType ChannelType) map[string]bool {
moderatedPermissions := make(map[string]bool)
@@ -597,11 +606,22 @@ func (r *Role) GetChannelModeratedPermissions(channelType ChannelType) map[strin
}
if moderated == permission {
// Special case where the channel moderated permission for `manage_members` is different depending on whether the channel is private or public
// Special case where the channel moderated permission for `manage_members` is different depending
// on whether the channel is private or public
if moderated == PermissionManagePublicChannelMembers.Id || moderated == PermissionManagePrivateChannelMembers.Id {
canManagePublic := channelType == ChannelTypeOpen && moderated == PermissionManagePublicChannelMembers.Id
canManagePrivate := channelType == ChannelTypePrivate && moderated == PermissionManagePrivateChannelMembers.Id
moderatedPermissions[moderatedPermissionValue] = canManagePublic || canManagePrivate
// Special case where the channel moderated permission for `manage_bookmarks` is different
// depending on whether the channel is private or public.
//
// Only AddBookmark is checked even if the permission includes four (add, delete, edit and
// order) as all of them are enabled or disabled in together
} else if isModeratedBookmarkPermission(moderated) {
canManagePublic := channelType == ChannelTypeOpen && moderated == PermissionAddBookmarkPublicChannel.Id
canManagePrivate := channelType == ChannelTypePrivate && moderated == PermissionAddBookmarkPrivateChannel.Id
moderatedPermissions[moderatedPermissionValue] = canManagePublic || canManagePrivate
} else {
moderatedPermissions[moderatedPermissionValue] = true
}
@@ -783,6 +803,14 @@ func MakeDefaultRoles() map[string]*Role {
PermissionManagePrivateChannelMembers.Id,
PermissionDeletePost.Id,
PermissionEditPost.Id,
PermissionAddBookmarkPublicChannel.Id,
PermissionEditBookmarkPublicChannel.Id,
PermissionDeleteBookmarkPublicChannel.Id,
PermissionOrderBookmarkPublicChannel.Id,
PermissionAddBookmarkPrivateChannel.Id,
PermissionEditBookmarkPrivateChannel.Id,
PermissionDeleteBookmarkPrivateChannel.Id,
PermissionOrderBookmarkPrivateChannel.Id,
},
SchemeManaged: true,
BuiltIn: true,
@@ -795,6 +823,14 @@ func MakeDefaultRoles() map[string]*Role {
Permissions: []string{
PermissionManageChannelRoles.Id,
PermissionUseGroupMentions.Id,
PermissionAddBookmarkPublicChannel.Id,
PermissionEditBookmarkPublicChannel.Id,
PermissionDeleteBookmarkPublicChannel.Id,
PermissionOrderBookmarkPublicChannel.Id,
PermissionAddBookmarkPrivateChannel.Id,
PermissionEditBookmarkPrivateChannel.Id,
PermissionDeleteBookmarkPrivateChannel.Id,
PermissionOrderBookmarkPrivateChannel.Id,
},
SchemeManaged: true,
BuiltIn: true,
@@ -873,6 +909,14 @@ func MakeDefaultRoles() map[string]*Role {
PermissionConvertPrivateChannelToPublic.Id,
PermissionDeletePost.Id,
PermissionDeleteOthersPosts.Id,
PermissionAddBookmarkPublicChannel.Id,
PermissionEditBookmarkPublicChannel.Id,
PermissionDeleteBookmarkPublicChannel.Id,
PermissionOrderBookmarkPublicChannel.Id,
PermissionAddBookmarkPrivateChannel.Id,
PermissionEditBookmarkPrivateChannel.Id,
PermissionDeleteBookmarkPrivateChannel.Id,
PermissionOrderBookmarkPrivateChannel.Id,
},
SchemeManaged: true,
BuiltIn: true,

Просмотреть файл

@@ -86,6 +86,10 @@ const (
WebsocketEventAcknowledgementRemoved WebsocketEventType = "post_acknowledgement_removed"
WebsocketEventPersistentNotificationTriggered WebsocketEventType = "persistent_notification_triggered"
WebsocketEventHostedCustomerSignupProgressUpdated WebsocketEventType = "hosted_customer_signup_progress_updated"
WebsocketEventChannelBookmarkCreated = "channel_bookmark_created"
WebsocketEventChannelBookmarkUpdated = "channel_bookmark_updated"
WebsocketEventChannelBookmarkDeleted = "channel_bookmark_deleted"
WebsocketEventChannelBookmarkSorted = "channel_bookmark_sorted"
WebsocketPresenceIndicator WebsocketEventType = "presence"
)