diff --git a/api/Makefile b/api/Makefile index f59c5ec36f..67c5726f65 100644 --- a/api/Makefile +++ b/api/Makefile @@ -50,6 +50,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/imports.yaml >> $(V4_YAML) @cat $(V4_SRC)/exports.yaml >> $(V4_YAML) @cat $(V4_SRC)/ip_filters.yaml >> $(V4_YAML) + @cat $(V4_SRC)/bookmarks.yaml >> $(V4_YAML) @cat $(V4_SRC)/reports.yaml >> $(V4_YAML) @cat $(V4_SRC)/limits.yaml >> $(V4_YAML) @cat $(V4_SRC)/outgoing_oauth_connections.yaml >> $(V4_YAML) diff --git a/api/v4/source/bookmarks.yaml b/api/v4/source/bookmarks.yaml new file mode 100644 index 0000000000..20d05027bc --- /dev/null +++ b/api/v4/source/bookmarks.yaml @@ -0,0 +1,291 @@ + /api/v4/channels/{channel_id}/bookmarks: + get: + tags: + - bookmarks + summary: Get channel bookmarks for Channel + description: | + __Minimum server version__: 9.5 + operationId: ListChannelBookmarksForChannel + parameters: + - name: channel_id + in: path + description: Channel GUID + required: true + schema: + type: string + - name: bookmarks_since + in: query + description: | + Timestamp to filter the bookmarks with. If set, the + endpoint returns bookmarks that have been added, updated + or deleted since its value + required: false + schema: + type: number + format: int64 + responses: + "201": + description: Channel Bookmarks retrieval successful + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ChannelBookmarkWithFileInfo" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + post: + tags: + - bookmarks + summary: Create channel bookmark + description: | + Creates a new channel bookmark for this channel. + + __Minimum server version__: 9.5 + + ##### Permissions + Must have the `add_bookmark_public_channel` or + `add_bookmark_private_channel` depending on the channel + type. If the channel is a DM or GM, must be a non-guest + member. + operationId: CreateChannelBookmark + parameters: + - name: channel_id + in: path + description: Channel GUID + required: true + schema: + type: string + body: + requestBody: + content: + application/json: + schema: + type: object + required: + - display_name + - type + properties: + file_id: + type: string + description: The ID of the file associated with the channel bookmark. Required for bookmarks of type 'file' + display_name: + type: string + description: The name of the channel bookmark + link_url: + type: string + description: The URL associated with the channel bookmark. Required for bookmarks of type 'link' + image_url: + type: string + description: The URL of the image associated with the channel bookmark. Optional, only applies for bookmarks of type 'link' + emoji: + type: string + description: The emoji of the channel bookmark + type: + type: string + enum: [link, file] + description: | + * `link` for channel bookmarks that reference a link. `link_url` is requied + * `file` for channel bookmarks that reference a file. `file_id` is required + description: Channel Bookmark object to be created + required: true + responses: + "201": + description: Channel Bookmark creation successful + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelBookmarkWithFileInfo" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /api/v4/channels/{channel_id}/bookmarks/{bookmark_id}: + patch: + tags: + - bookmarks + summary: Update channel bookmark + description: | + Partially update a channel bookmark by providing only the + fields you want to update. Ommited fields will not be + updated. The fields that can be updated are defined in the + request body, all other provided fields will be ignored. + + __Minimum server version__: 9.5 + + ##### Permissions + Must have the `edit_bookmark_public_channel` or + `edit_bookmark_private_channel` depending on the channel + type. If the channel is a DM or GM, must be a non-guest + member. + operationId: UpdateChannelBookmark + parameters: + - name: channel_id + in: path + description: Channel GUID + required: true + schema: + type: string + - name: bookmark_id + in: path + description: Bookmark GUID + required: true + schema: + type: string + body: + requestBody: + content: + application/json: + schema: + type: object + properties: + file_id: + type: string + description: The ID of the file associated with the channel bookmark. Required for bookmarks of type 'file' + display_name: + type: string + description: The name of the channel bookmark + sort_order: + type: integer + format: int64 + description: The order of the channel bookmark + link_url: + type: string + description: The URL associated with the channel bookmark. Required for type bookmarks of type 'link' + image_url: + type: string + description: The URL of the image associated with the channel bookmark + emoji: + type: string + description: The emoji of the channel bookmark + type: + type: string + enum: [link, file] + description: | + * `link` for channel bookmarks that reference a link. `link_url` is requied + * `file` for channel bookmarks that reference a file. `file_id` is required + description: Channel Bookmark object to be updated + required: true + responses: + "200": + description: Channel Bookmark update successful + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateChannelBookmarkResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + delete: + tags: + - bookmarks + summary: Delete channel bookmark + description: | + Archives a channel bookmark. This will set the `deleteAt` to + the current timestamp in the database. + + __Minimum server version__: 9.5 + + ##### Permissions + Must have the `delete_bookmark_public_channel` or + `delete_bookmark_private_channel` depending on the channel + type. If the channel is a DM or GM, must be a non-guest + member. + operationId: DeleteChannelBookmark + parameters: + - name: channel_id + in: path + description: Channel GUID + required: true + schema: + type: string + - name: bookmark_id + in: path + description: Bookmark GUID + required: true + schema: + type: string + responses: + "200": + description: Channel Bookmark deletion successful + content: + application/json: + schema: + $ref: "#/components/schemas/ChannelBookmarkWithFileInfo" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /api/v4/channels/{channel_id}/bookmarks/{bookmark_id}/sort_order: + post: + tags: + - bookmarks + summary: Update channel bookmark's order + description: | + Updates the order of a channel bookmark, setting its new order + from the parameters and updating the rest of the bookmarks of + the channel to accomodate for this change. + + __Minimum server version__: 9.5 + + ##### Permissions + Must have the `order_bookmark_public_channel` or + `order_bookmark_private_channel` depending on the channel + type. If the channel is a DM or GM, must be a non-guest + member. + operationId: UpdateChannelBookmarkSortOrder + parameters: + - name: channel_id + in: path + description: Channel GUID + required: true + schema: + type: string + - name: bookmark_id + in: path + description: Bookmark GUID + required: true + schema: + type: string + body: + requestBody: + content: + application/json: + schema: + type: number + format: int64 + description: The new sort order for the Channel Bookmark + responses: + "200": + description: Channel Bookmark Sort Order update successful + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ChannelBookmarkWithFileInfo" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 6ca07dbc43..358cb19528 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -298,6 +298,68 @@ components: type: array items: $ref: "#/components/schemas/ChannelWithTeamData" + ChannelBookmark: + type: object + properties: + id: + type: string + create_at: + description: The time in milliseconds a channel bookmark was created + type: integer + format: int64 + update_at: + description: The time in milliseconds a channel bookmark was last updated + type: integer + format: int64 + delete_at: + description: The time in milliseconds a channel bookmark was deleted + type: integer + format: int64 + channel_id: + type: string + owner_id: + description: The ID of the user that the channel bookmark belongs to + type: string + file_id: + description: The ID of the file associated with the channel bookmark + type: string + display_name: + type: string + sort_order: + description: The order of the channel bookmark + type: integer + format: int64 + link_url: + description: The URL associated with the channel bookmark + type: string + image_url: + description: The URL of the image associated with the channel bookmark + type: string + emoji: + type: string + type: + type: string + enum: [link, file] + original_id: + description: The ID of the original channel bookmark + type: string + parent_id: + description: The ID of the parent channel bookmark + type: string + ChannelBookmarkWithFileInfo: + allOf: + - $ref: "#/components/schemas/ChannelBookmark" + - type: object + properties: + file: + $ref: "#/components/schemas/FileInfo" + UpdateChannelBookmarkResponse: + type: object + properties: + updated: + $ref: "#/components/schemas/ChannelBookmarkWithFileInfo" + deleted: + $ref: "#/components/schemas/ChannelBookmarkWithFileInfo" Post: type: object properties: diff --git a/api/v4/source/files.yaml b/api/v4/source/files.yaml index 567d2e5fe7..b4d9b2a1c3 100644 --- a/api/v4/source/files.yaml +++ b/api/v4/source/files.yaml @@ -19,6 +19,12 @@ Server versions 4.8 and higher support both types of requests. + __Minimum server version__: 9.4 + + Starting with server version 9.4 when uploading a file for a channel bookmark, the bookmark=true query + parameter should be included in the query string + + ##### Permissions Must have `upload_file` permission. diff --git a/api/v4/source/introduction.yaml b/api/v4/source/introduction.yaml index 2bc4d165fc..8b87f6693f 100644 --- a/api/v4/source/introduction.yaml +++ b/api/v4/source/introduction.yaml @@ -490,6 +490,8 @@ tags: description: Endpoints for uploading and interacting with files. - name: uploads description: Endpoints for creating and performing file uploads. + - name: bookmarks + description: Endpoints for creating, getting and interacting with channel bookmarks. - name: preferences description: Endpoints for saving and modifying user preferences. - name: status @@ -569,6 +571,7 @@ x-tagGroups: - threads - files - uploads + - bookmarks - preferences - status - emoji diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/bookmark_permissions_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/bookmark_permissions_spec.ts new file mode 100644 index 0000000000..358efc2cb5 --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/permissions/bookmark_permissions_spec.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// *************************************************************** +// - [#] indicates a test step (e.g. # Go to a page) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element ID when selecting an element. Create one if none. +// *************************************************************** + +// Stage: @prod +// Group: @channels @enterprise @permissions + +import * as TIMEOUTS from '../../../../fixtures/timeouts'; + +const deleteExistingTeamOverrideSchemes = () => { + cy.apiGetSchemes('team').then(({schemes}) => { + schemes.forEach((scheme) => { + cy.apiDeleteScheme(scheme.id); + }); + }); +}; + +const checkChannelBookmarksPermissionsAreVisibleAndSet = () => { + const permissionRowIds = ['all_users-public_channel-manage_public_channel_bookmarks-checkbox', + 'all_users-private_channel-manage_private_channel_bookmarks-checkbox']; + + permissionRowIds.forEach((id) => { + cy.findByTestId(id).then((el) => { + expect(el.hasClass('checked')).to.be.true; + }); + }); +}; + +describe('Revoke Bookmarks Permissions', () => { + before(() => { + cy.apiRequireLicense(); + cy.apiInitSetup(); + deleteExistingTeamOverrideSchemes(); + }); + + beforeEach(() => { + cy.apiLogout(); + cy.apiAdminLogin(); + cy.apiResetRoles(); + }); + + it('Channel Bookmarks permissions should be visible and set in the system scheme', () => { + cy.apiAdminLogin(); + + // # Go to `User Management / Permissions` section + cy.visit('/admin_console/user_management/permissions'); + + // # Click `Edit Scheme` on System Scheme + cy.findByTestId('systemScheme-link').should('be.visible').click().wait(TIMEOUTS.HALF_SEC); + + // # Find and ensure that manage bookmarks permissions are visible and set + checkChannelBookmarksPermissionsAreVisibleAndSet(); + }); + + it('Channel Bookmarks permissions should be visible and set in a custom scheme', () => { + cy.apiAdminLogin(); + + // # Go to `User Management / Permissions` section + cy.visit('/admin_console/user_management/permissions'); + + // # Click `New Team Override Scheme` + cy.findByTestId('team-override-schemes-link').should('be.visible').click().wait(TIMEOUTS.HALF_SEC); + + // # Type Name and Description + cy.get('#scheme-name').should('be.visible').type('custom test schema'); + cy.get('#scheme-description').type('description'); + + // # Save scheme + cy.get('#saveSetting').click().wait(TIMEOUTS.TWO_SEC); + + // # Edit the schema + cy.findByTestId('custom test schema-edit').should('be.visible').click().wait(TIMEOUTS.HALF_SEC); + + // # Find and ensure that manage bookmarks permissions are visible and set + checkChannelBookmarksPermissionsAreVisibleAndSet(); + }); +}); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_moderation/helpers.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_moderation/helpers.js index f694dda25d..6ec223c286 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_moderation/helpers.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_moderation/helpers.js @@ -243,6 +243,12 @@ export const enableDisableAllChannelModeratedPermissionsViaAPI = (channel, enabl guests: enable, }, }, + { + name: 'manage_bookmarks', + roles: { + members: enable, + }, + }, ], }, ); diff --git a/e2e-tests/cypress/tests/support/api/role.js b/e2e-tests/cypress/tests/support/api/role.js index 86208dcbfc..707ff49b4d 100644 --- a/e2e-tests/cypress/tests/support/api/role.js +++ b/e2e-tests/cypress/tests/support/api/role.js @@ -9,15 +9,15 @@ import xor from 'lodash.xor'; // ***************************************************************************** export const defaultRolesPermissions = { - channel_admin: 'use_channel_mentions remove_reaction manage_public_channel_members use_group_mentions manage_channel_roles manage_private_channel_members add_reaction read_public_channel_groups create_post read_private_channel_groups', + channel_admin: 'use_channel_mentions remove_reaction manage_public_channel_members use_group_mentions manage_channel_roles manage_private_channel_members add_reaction read_public_channel_groups create_post read_private_channel_groups add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel', channel_guest: 'upload_file edit_post create_post use_channel_mentions read_channel read_channel_content add_reaction remove_reaction', - channel_user: 'manage_private_channel_members read_public_channel_groups delete_post read_private_channel_groups use_group_mentions manage_private_channel_properties delete_public_channel add_reaction manage_public_channel_properties edit_post upload_file use_channel_mentions get_public_link read_channel read_channel_content delete_private_channel manage_public_channel_members create_post remove_reaction', + channel_user: 'manage_private_channel_members read_public_channel_groups delete_post read_private_channel_groups use_group_mentions manage_private_channel_properties delete_public_channel add_reaction manage_public_channel_properties edit_post upload_file use_channel_mentions get_public_link read_channel read_channel_content delete_private_channel manage_public_channel_members create_post remove_reaction add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel', custom_group_user: '', playbook_admin: 'playbook_private_manage_properties playbook_public_make_private playbook_public_manage_members playbook_public_manage_roles playbook_public_manage_properties playbook_private_manage_members playbook_private_manage_roles', playbook_member: 'playbook_public_view playbook_public_manage_members playbook_public_manage_properties playbook_private_view playbook_private_manage_members playbook_private_manage_properties run_create', run_admin: 'run_manage_properties run_manage_members', run_member: 'run_view', - system_admin: 'sysconsole_write_environment_elasticsearch playbook_public_manage_properties sysconsole_write_authentication_ldap run_view manage_jobs manage_roles playbook_public_create manage_public_channel_properties sysconsole_read_plugins delete_post purge_elasticsearch_indexes sysconsole_read_integrations_bot_accounts read_data_retention_job manage_private_channel_members create_elasticsearch_post_indexing_job sysconsole_read_authentication_guest_access create_elasticsearch_post_aggregation_job join_public_teams sysconsole_read_site_public_links add_saml_idp_cert sysconsole_write_site_announcement_banner sysconsole_write_site_notices sysconsole_read_experimental_feature_flags sysconsole_read_site_users_and_teams manage_slash_commands sysconsole_read_authentication_ldap read_channel read_channel_content sysconsole_write_authentication_password list_users_without_team sysconsole_read_authentication_email add_saml_public_cert playbook_private_create promote_guest sysconsole_read_user_management_system_roles manage_public_channel_members create_data_retention_job add_saml_private_cert sysconsole_write_user_management_users sysconsole_read_compliance_compliance_monitoring playbook_public_manage_members sysconsole_write_environment_database sysconsole_write_user_management_teams playbook_private_manage_roles read_public_channel sysconsole_write_plugins sysconsole_read_authentication_openid sysconsole_write_user_management_groups sysconsole_write_site_file_sharing_and_downloads playbook_private_manage_properties sysconsole_read_site_customization join_public_channels add_user_to_team restore_custom_group download_compliance_export_result sysconsole_write_user_management_system_roles sysconsole_write_environment_session_lengths create_custom_group manage_private_channel_properties create_post_public remove_ldap_private_cert sysconsole_write_site_public_links import_team sysconsole_read_environment_developer sysconsole_read_environment_database sysconsole_read_environment_web_server use_channel_mentions view_team remove_others_reactions sysconsole_read_environment_session_lengths sysconsole_write_integrations_bot_accounts playbook_public_view use_group_mentions sysconsole_write_environment_web_server add_ldap_private_cert read_public_channel_groups invite_guest sysconsole_read_environment_smtp create_post sysconsole_read_about_edition_and_license sysconsole_read_authentication_signup sysconsole_read_authentication_saml sysconsole_read_environment_file_storage sysconsole_write_experimental_feature_flags sysconsole_write_site_localization sysconsole_write_environment_rate_limiting sysconsole_read_environment_rate_limiting sysconsole_read_products_boards get_saml_cert_status sysconsole_read_environment_high_availability manage_secure_connections read_compliance_export_job sysconsole_write_compliance_custom_terms_of_service read_user_access_token edit_post sysconsole_write_environment_logging sysconsole_read_environment_push_notification_server sysconsole_write_site_customization read_other_users_teams read_elasticsearch_post_aggregation_job sysconsole_write_compliance_data_retention_policy sysconsole_read_user_management_permissions sysconsole_read_site_emoji sysconsole_read_compliance_data_retention_policy read_license_information sysconsole_read_experimental_features read_deleted_posts sysconsole_read_environment_logging sysconsole_read_reporting_site_statistics test_elasticsearch sysconsole_read_site_posts add_reaction sysconsole_write_authentication_signup manage_outgoing_webhooks create_post_ephemeral sysconsole_read_environment_image_proxy invite_user manage_others_outgoing_webhooks create_user_access_token sysconsole_write_environment_image_proxy sysconsole_write_products_boards read_elasticsearch_post_indexing_job purge_bleve_indexes sysconsole_write_environment_performance_monitoring sysconsole_write_authentication_guest_access sysconsole_read_compliance_custom_terms_of_service edit_others_posts sysconsole_write_billing get_saml_metadata_from_idp sysconsole_write_authentication_saml create_post_bleve_indexes_job invalidate_caches sysconsole_write_experimental_bleve view_members manage_others_bots run_create join_private_teams convert_private_channel_to_public read_audits assign_bot read_jobs remove_user_from_team revoke_user_access_token manage_team sysconsole_read_reporting_server_logs get_public_link manage_others_slash_commands manage_system delete_public_channel read_private_channel_groups sysconsole_read_authentication_mfa delete_emojis list_private_teams create_emojis sysconsole_read_billing sysconsole_write_site_emoji invalidate_email_invite sysconsole_write_environment_file_storage sysconsole_write_compliance_compliance_monitoring remove_saml_public_cert sysconsole_read_compliance_compliance_export sysconsole_read_site_localization manage_team_roles list_public_teams get_logs sysconsole_write_integrations_integration_management sysconsole_read_integrations_cors manage_oauth manage_outgoing_oauth_connections delete_others_emojis sysconsole_write_integrations_gif manage_incoming_webhooks sysconsole_write_authentication_email create_private_channel playbook_private_make_public manage_bots add_ldap_public_cert remove_ldap_public_cert sysconsole_write_site_notifications sysconsole_write_environment_developer playbook_private_manage_members sysconsole_read_user_management_teams edit_custom_group remove_reaction playbook_public_manage_roles sysconsole_write_reporting_server_logs read_others_bots sysconsole_write_site_posts sysconsole_read_site_notifications sysconsole_read_authentication_password playbook_private_view manage_system_wide_oauth get_analytics list_team_channels sysconsole_write_user_management_channels delete_private_channel manage_custom_group_members test_s3 create_ldap_sync_job sysconsole_read_integrations_integration_management test_site_url recycle_database_connections sysconsole_read_site_announcement_banner test_email manage_shared_channels read_bots sysconsole_write_environment_smtp sysconsole_read_experimental_bleve sysconsole_write_environment_push_notification_server sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_write_reporting_site_statistics sysconsole_write_site_users_and_teams demote_to_guest create_team test_ldap remove_saml_idp_cert delete_others_posts edit_other_users sysconsole_write_reporting_team_statistics sysconsole_read_integrations_gif sysconsole_read_site_notices sysconsole_write_about_edition_and_license manage_others_incoming_webhooks run_manage_members create_bot sysconsole_write_authentication_mfa sysconsole_read_user_management_users assign_system_admin_role sysconsole_write_experimental_features edit_brand create_group_channel sysconsole_write_authentication_openid create_direct_channel manage_license_information reload_config manage_channel_roles sysconsole_read_user_management_groups create_compliance_export_job read_ldap_sync_job upload_file sysconsole_read_site_file_sharing_and_downloads delete_custom_group sysconsole_read_user_management_channels sysconsole_write_compliance_compliance_export remove_saml_private_cert sysconsole_read_environment_performance_monitoring create_public_channel sysconsole_write_integrations_cors sysconsole_write_environment_high_availability playbook_public_make_private run_manage_properties sysconsole_read_reporting_team_statistics convert_public_channel_to_private', + system_admin: 'sysconsole_write_environment_elasticsearch playbook_public_manage_properties sysconsole_write_authentication_ldap run_view manage_jobs manage_roles playbook_public_create manage_public_channel_properties sysconsole_read_plugins delete_post purge_elasticsearch_indexes sysconsole_read_integrations_bot_accounts read_data_retention_job manage_private_channel_members create_elasticsearch_post_indexing_job sysconsole_read_authentication_guest_access create_elasticsearch_post_aggregation_job join_public_teams sysconsole_read_site_public_links add_saml_idp_cert sysconsole_write_site_announcement_banner sysconsole_write_site_notices sysconsole_read_experimental_feature_flags sysconsole_read_site_users_and_teams manage_slash_commands sysconsole_read_authentication_ldap read_channel read_channel_content sysconsole_write_authentication_password list_users_without_team sysconsole_read_authentication_email add_saml_public_cert playbook_private_create promote_guest sysconsole_read_user_management_system_roles manage_public_channel_members create_data_retention_job add_saml_private_cert sysconsole_write_user_management_users sysconsole_read_compliance_compliance_monitoring playbook_public_manage_members sysconsole_write_environment_database sysconsole_write_user_management_teams playbook_private_manage_roles read_public_channel sysconsole_write_plugins sysconsole_read_authentication_openid sysconsole_write_user_management_groups sysconsole_write_site_file_sharing_and_downloads playbook_private_manage_properties sysconsole_read_site_customization join_public_channels add_user_to_team restore_custom_group download_compliance_export_result sysconsole_write_user_management_system_roles sysconsole_write_environment_session_lengths create_custom_group manage_private_channel_properties create_post_public remove_ldap_private_cert sysconsole_write_site_public_links import_team sysconsole_read_environment_developer sysconsole_read_environment_database sysconsole_read_environment_web_server use_channel_mentions view_team remove_others_reactions sysconsole_read_environment_session_lengths sysconsole_write_integrations_bot_accounts playbook_public_view use_group_mentions sysconsole_write_environment_web_server add_ldap_private_cert read_public_channel_groups invite_guest sysconsole_read_environment_smtp create_post sysconsole_read_about_edition_and_license sysconsole_read_authentication_signup sysconsole_read_authentication_saml sysconsole_read_environment_file_storage sysconsole_write_experimental_feature_flags sysconsole_write_site_localization sysconsole_write_environment_rate_limiting sysconsole_read_environment_rate_limiting sysconsole_read_products_boards get_saml_cert_status sysconsole_read_environment_high_availability manage_secure_connections read_compliance_export_job sysconsole_write_compliance_custom_terms_of_service read_user_access_token edit_post sysconsole_write_environment_logging sysconsole_read_environment_push_notification_server sysconsole_write_site_customization read_other_users_teams read_elasticsearch_post_aggregation_job sysconsole_write_compliance_data_retention_policy sysconsole_read_user_management_permissions sysconsole_read_site_emoji sysconsole_read_compliance_data_retention_policy read_license_information sysconsole_read_experimental_features read_deleted_posts sysconsole_read_environment_logging sysconsole_read_reporting_site_statistics test_elasticsearch sysconsole_read_site_posts add_reaction sysconsole_write_authentication_signup manage_outgoing_webhooks create_post_ephemeral sysconsole_read_environment_image_proxy invite_user manage_others_outgoing_webhooks create_user_access_token sysconsole_write_environment_image_proxy sysconsole_write_products_boards read_elasticsearch_post_indexing_job purge_bleve_indexes sysconsole_write_environment_performance_monitoring sysconsole_write_authentication_guest_access sysconsole_read_compliance_custom_terms_of_service edit_others_posts sysconsole_write_billing get_saml_metadata_from_idp sysconsole_write_authentication_saml create_post_bleve_indexes_job invalidate_caches sysconsole_write_experimental_bleve view_members manage_others_bots run_create join_private_teams convert_private_channel_to_public read_audits assign_bot read_jobs remove_user_from_team revoke_user_access_token manage_team sysconsole_read_reporting_server_logs get_public_link manage_others_slash_commands manage_system delete_public_channel read_private_channel_groups sysconsole_read_authentication_mfa delete_emojis list_private_teams create_emojis sysconsole_read_billing sysconsole_write_site_emoji invalidate_email_invite sysconsole_write_environment_file_storage sysconsole_write_compliance_compliance_monitoring remove_saml_public_cert sysconsole_read_compliance_compliance_export sysconsole_read_site_localization manage_team_roles list_public_teams get_logs sysconsole_write_integrations_integration_management sysconsole_read_integrations_cors manage_oauth manage_outgoing_oauth_connections delete_others_emojis sysconsole_write_integrations_gif manage_incoming_webhooks sysconsole_write_authentication_email create_private_channel playbook_private_make_public manage_bots add_ldap_public_cert remove_ldap_public_cert sysconsole_write_site_notifications sysconsole_write_environment_developer playbook_private_manage_members sysconsole_read_user_management_teams edit_custom_group remove_reaction playbook_public_manage_roles sysconsole_write_reporting_server_logs read_others_bots sysconsole_write_site_posts sysconsole_read_site_notifications sysconsole_read_authentication_password playbook_private_view manage_system_wide_oauth get_analytics list_team_channels sysconsole_write_user_management_channels delete_private_channel manage_custom_group_members test_s3 create_ldap_sync_job sysconsole_read_integrations_integration_management test_site_url recycle_database_connections sysconsole_read_site_announcement_banner test_email manage_shared_channels read_bots sysconsole_write_environment_smtp sysconsole_read_experimental_bleve sysconsole_write_environment_push_notification_server sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_write_reporting_site_statistics sysconsole_write_site_users_and_teams demote_to_guest create_team test_ldap remove_saml_idp_cert delete_others_posts edit_other_users sysconsole_write_reporting_team_statistics sysconsole_read_integrations_gif sysconsole_read_site_notices sysconsole_write_about_edition_and_license manage_others_incoming_webhooks run_manage_members create_bot sysconsole_write_authentication_mfa sysconsole_read_user_management_users assign_system_admin_role sysconsole_write_experimental_features edit_brand create_group_channel sysconsole_write_authentication_openid create_direct_channel manage_license_information reload_config manage_channel_roles sysconsole_read_user_management_groups create_compliance_export_job read_ldap_sync_job upload_file sysconsole_read_site_file_sharing_and_downloads delete_custom_group sysconsole_read_user_management_channels sysconsole_write_compliance_compliance_export remove_saml_private_cert sysconsole_read_environment_performance_monitoring create_public_channel sysconsole_write_integrations_cors sysconsole_write_environment_high_availability playbook_public_make_private run_manage_properties sysconsole_read_reporting_team_statistics convert_public_channel_to_private add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel', system_custom_group_admin: 'create_custom_group edit_custom_group delete_custom_group restore_custom_group manage_custom_group_members', system_guest: 'create_group_channel create_direct_channel', system_manager: ' sysconsole_read_site_announcement_banner manage_private_channel_properties edit_brand read_private_channel_groups manage_private_channel_members manage_team_roles sysconsole_write_environment_session_lengths sysconsole_read_site_emoji sysconsole_write_environment_developer sysconsole_read_user_management_groups sysconsole_write_user_management_groups sysconsole_write_environment_rate_limiting delete_private_channel sysconsole_read_environment_performance_monitoring sysconsole_read_environment_rate_limiting sysconsole_write_user_management_teams sysconsole_write_integrations_integration_management sysconsole_write_site_public_links sysconsole_read_authentication_ldap sysconsole_write_integrations_cors reload_config sysconsole_write_user_management_channels sysconsole_read_environment_high_availability sysconsole_read_site_users_and_teams sysconsole_read_user_management_teams sysconsole_write_site_users_and_teams sysconsole_read_site_customization sysconsole_write_environment_high_availability sysconsole_read_integrations_bot_accounts sysconsole_read_authentication_guest_access sysconsole_read_site_public_links read_elasticsearch_post_indexing_job sysconsole_read_user_management_channels sysconsole_read_reporting_team_statistics invalidate_caches sysconsole_read_authentication_signup read_elasticsearch_post_aggregation_job sysconsole_write_environment_smtp manage_public_channel_members list_public_teams add_user_to_team sysconsole_read_environment_web_server sysconsole_read_site_localization get_logs sysconsole_write_site_posts sysconsole_write_integrations_bot_accounts sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_read_environment_smtp list_private_teams read_public_channel_groups sysconsole_write_environment_file_storage sysconsole_write_integrations_gif manage_public_channel_properties sysconsole_write_environment_performance_monitoring sysconsole_write_site_notifications sysconsole_read_site_notifications sysconsole_read_environment_image_proxy sysconsole_write_site_announcement_banner sysconsole_write_site_emoji test_site_url sysconsole_read_integrations_gif sysconsole_write_environment_logging convert_public_channel_to_private get_analytics sysconsole_read_user_management_permissions sysconsole_write_environment_image_proxy test_elasticsearch recycle_database_connections sysconsole_write_site_localization sysconsole_read_reporting_server_logs create_elasticsearch_post_indexing_job sysconsole_read_reporting_site_statistics test_ldap delete_public_channel sysconsole_write_environment_push_notification_server read_license_information sysconsole_write_products_boards sysconsole_read_about_edition_and_license convert_private_channel_to_public sysconsole_read_integrations_integration_management create_elasticsearch_post_aggregation_job purge_elasticsearch_indexes sysconsole_read_environment_database join_public_teams sysconsole_read_authentication_email sysconsole_read_environment_push_notification_server view_team read_channel sysconsole_read_authentication_password read_ldap_sync_job sysconsole_read_integrations_cors sysconsole_read_environment_logging manage_team sysconsole_read_authentication_openid read_public_channel sysconsole_write_environment_elasticsearch sysconsole_read_plugins manage_channel_roles remove_user_from_team test_email sysconsole_write_site_file_sharing_and_downloads test_s3 sysconsole_read_site_file_sharing_and_downloads sysconsole_read_site_notices sysconsole_read_environment_file_storage join_private_teams sysconsole_read_products_boards sysconsole_read_environment_session_lengths sysconsole_write_environment_database sysconsole_read_authentication_saml sysconsole_read_authentication_mfa sysconsole_write_site_notices sysconsole_write_environment_web_server sysconsole_read_site_posts sysconsole_read_environment_developer sysconsole_write_site_customization manage_outgoing_oauth_connections', @@ -27,7 +27,7 @@ export const defaultRolesPermissions = { system_user: 'delete_custom_group create_emojis edit_custom_group create_direct_channel view_members join_public_teams restore_custom_group create_custom_group manage_custom_group_members delete_emojis list_public_teams create_team create_group_channel', system_user_access_token: 'create_user_access_token read_user_access_token revoke_user_access_token', system_user_manager: 'sysconsole_read_authentication_password sysconsole_read_authentication_openid sysconsole_write_user_management_groups list_private_teams sysconsole_read_user_management_groups sysconsole_read_authentication_email manage_public_channel_properties delete_private_channel sysconsole_read_authentication_signup read_private_channel_groups sysconsole_read_user_management_teams test_ldap read_channel view_team manage_team sysconsole_write_user_management_teams manage_channel_roles sysconsole_read_authentication_saml sysconsole_read_authentication_guest_access convert_private_channel_to_public sysconsole_read_user_management_permissions join_public_teams sysconsole_write_user_management_channels read_public_channel_groups sysconsole_read_user_management_channels list_public_teams manage_team_roles join_private_teams manage_public_channel_members convert_public_channel_to_private remove_user_from_team sysconsole_read_authentication_ldap manage_private_channel_properties delete_public_channel manage_private_channel_members read_public_channel add_user_to_team sysconsole_read_authentication_mfa read_ldap_sync_job', - team_admin: 'manage_others_slash_commands manage_channel_roles manage_others_outgoing_webhooks manage_team_roles use_channel_mentions manage_incoming_webhooks manage_slash_commands manage_public_channel_members convert_private_channel_to_public manage_private_channel_members manage_team convert_public_channel_to_private use_group_mentions delete_post read_public_channel_groups delete_others_posts playbook_private_manage_roles add_reaction remove_reaction remove_user_from_team read_private_channel_groups manage_outgoing_webhooks create_post playbook_public_manage_roles import_team manage_others_incoming_webhooks', + team_admin: 'manage_others_slash_commands manage_channel_roles manage_others_outgoing_webhooks manage_team_roles use_channel_mentions manage_incoming_webhooks manage_slash_commands manage_public_channel_members convert_private_channel_to_public manage_private_channel_members manage_team convert_public_channel_to_private use_group_mentions delete_post read_public_channel_groups delete_others_posts playbook_private_manage_roles add_reaction remove_reaction remove_user_from_team read_private_channel_groups manage_outgoing_webhooks create_post playbook_public_manage_roles import_team manage_others_incoming_webhooks add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel', team_guest: 'view_team', team_post_all: 'create_post use_channel_mentions use_group_mentions', team_post_all_public: 'create_post_public use_channel_mentions use_group_mentions', diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index b1c3394eee..34270df84a 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -49,6 +49,8 @@ type Routes struct { ChannelMembersForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/channels/members' ChannelModerations *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/moderations' ChannelCategories *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/channels/categories' + ChannelBookmarks *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/bookmarks' + ChannelBookmark *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/bookmarks/{bookmark_id:[A-Za-z0-9]+}' Posts *mux.Router // 'api/v4/posts' Post *mux.Router // 'api/v4/posts/{post_id:[A-Za-z0-9]+}' @@ -193,6 +195,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.ChannelMembersForUser = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/members").Subrouter() api.BaseRoutes.ChannelModerations = api.BaseRoutes.Channel.PathPrefix("/moderations").Subrouter() api.BaseRoutes.ChannelCategories = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/categories").Subrouter() + api.BaseRoutes.ChannelBookmarks = api.BaseRoutes.Channel.PathPrefix("/bookmarks").Subrouter() + api.BaseRoutes.ChannelBookmark = api.BaseRoutes.ChannelBookmarks.PathPrefix("/{bookmark_id:[A-Za-z0-9]+}").Subrouter() api.BaseRoutes.Posts = api.BaseRoutes.APIRoot.PathPrefix("/posts").Subrouter() api.BaseRoutes.Post = api.BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter() @@ -323,6 +327,7 @@ func Init(srv *app.Server) (*API, error) { api.InitHostedCustomer() api.InitDrafts() api.InitIPFiltering() + api.InitChannelBookmarks() api.InitReports() api.InitLimits() api.InitOutgoingOAuthConnection() diff --git a/server/channels/api4/apitestlib.go b/server/channels/api4/apitestlib.go index f27245da32..66bc95b4ad 100644 --- a/server/channels/api4/apitestlib.go +++ b/server/channels/api4/apitestlib.go @@ -623,6 +623,41 @@ func (th *TestHelper) CreateUserWithAuth(authService string) *model.User { return user } +// CreateGuestAndClient creates a guest user, adds them to the basic +// team, basic channel and basic private channel, and generates an API +// client ready to use +func (th *TestHelper) CreateGuestAndClient() (*model.User, *model.Client4) { + id := model.NewId() + + // create a guest user and add it to the basic team and public/private channels + guest, cgErr := th.App.CreateGuest(th.Context, &model.User{ + Email: "test_guest" + id + "@sample.com", + Username: "guest_" + id, + Nickname: "guest_" + id, + Password: "Password1", + EmailVerified: true, + }) + if cgErr != nil { + panic(cgErr) + } + + _, _, tErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, guest.Id, th.SystemAdminUser.Id) + if tErr != nil { + panic(tErr) + } + th.AddUserToChannel(guest, th.BasicChannel) + th.AddUserToChannel(guest, th.BasicPrivateChannel) + + // create a client and login the guest + guestClient := th.CreateClient() + _, _, lErr := guestClient.Login(context.Background(), guest.Username, "Password1") + if lErr != nil { + panic(lErr) + } + + return guest, guestClient +} + func (th *TestHelper) SetupLdapConfig() { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = true @@ -809,6 +844,23 @@ func (th *TestHelper) CreateDmChannel(user *model.User) *model.Channel { return channel } +func (th *TestHelper) PatchChannelModerationsForMembers(channelId, name string, val bool) { + patch := []*model.ChannelModerationPatch{{ + Name: &name, + Roles: &model.ChannelModeratedRolesPatch{Members: model.NewBool(val)}, + }} + + channel, err := th.App.GetChannel(th.Context, channelId) + if err != nil { + panic(err) + } + + _, err = th.App.PatchChannelModerationsForChannel(th.Context, channel, patch) + if err != nil { + panic(err) + } +} + func (th *TestHelper) LoginBasic() { th.LoginBasicWithClient(th.Client) } diff --git a/server/channels/api4/channel_bookmark.go b/server/channels/api4/channel_bookmark.go new file mode 100644 index 0000000000..59656146d0 --- /dev/null +++ b/server/channels/api4/channel_bookmark.go @@ -0,0 +1,408 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/audit" +) + +func (api *API) InitChannelBookmarks() { + if api.srv.Config().FeatureFlags.ChannelBookmarks { + api.BaseRoutes.ChannelBookmarks.Handle("", api.APISessionRequired(createChannelBookmark)).Methods("POST") + api.BaseRoutes.ChannelBookmark.Handle("", api.APISessionRequired(updateChannelBookmark)).Methods("PATCH") + api.BaseRoutes.ChannelBookmark.Handle("/sort_order", api.APISessionRequired(updateChannelBookmarkSortOrder)).Methods("POST") + api.BaseRoutes.ChannelBookmark.Handle("", api.APISessionRequired(deleteChannelBookmark)).Methods("DELETE") + api.BaseRoutes.ChannelBookmarks.Handle("", api.APISessionRequired(listChannelBookmarksForChannel)).Methods("GET") + } +} + +func createChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Channels().License() == nil { + c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented) + return + } + + connectionID := r.Header.Get(model.ConnectionId) + + c.RequireChannelId() + if c.Err != nil { + return + } + + channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId) + if appErr != nil { + c.Err = appErr + return + } + + var channelBookmark *model.ChannelBookmark + err := json.NewDecoder(r.Body).Decode(&channelBookmark) + if err != nil || channelBookmark == nil { + c.SetInvalidParamWithErr("channelBookmark", err) + return + } + channelBookmark.ChannelId = c.Params.ChannelId + + auditRec := c.MakeAuditRecord("createChannelBookmark", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameterAuditable(auditRec, "channelBookmark", channelBookmark) + + switch channel.Type { + case model.ChannelTypeOpen: + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPublicChannel) { + c.SetPermissionError(model.PermissionAddBookmarkPublicChannel) + return + } + + case model.ChannelTypePrivate: + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPrivateChannel) { + c.SetPermissionError(model.PermissionAddBookmarkPrivateChannel) + return + } + + case model.ChannelTypeGroup, model.ChannelTypeDirect: + // Any member of DM/GMs but guests can manage channel bookmarks + if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil { + c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden) + return + } + + user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) + if gAppErr != nil { + c.Err = gAppErr + return + } + + if user.IsGuest() { + c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden) + return + } + + default: + c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.forbidden.app_error", nil, "", http.StatusForbidden) + return + } + + newChannelBookmark, appErr := c.App.CreateChannelBookmark(c.AppContext, channelBookmark, connectionID) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(newChannelBookmark) + auditRec.AddEventObjectType("channelBookmarkWithFileInfo") + c.LogAudit("display_name=" + newChannelBookmark.DisplayName) + + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(newChannelBookmark); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Channels().License() == nil { + c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented) + return + } + + connectionID := r.Header.Get(model.ConnectionId) + + c.RequireChannelId() + if c.Err != nil { + return + } + + var patch *model.ChannelBookmarkPatch + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil || patch == nil { + c.SetInvalidParamWithErr("channelBookmarkPatch", err) + return + } + + originalChannelBookmark, appErr := c.App.GetBookmark(c.Params.ChannelBookmarkId, false) + if appErr != nil { + c.Err = appErr + return + } + patchedBookmark := originalChannelBookmark.Clone() + auditRec := c.MakeAuditRecord("updateChannelBookmark", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameterAuditable(auditRec, "channelBookmark", patch) + + // The channel bookmark should belong to the same channel specified in the URL + if patchedBookmark.ChannelId != c.Params.ChannelId { + c.SetInvalidParam("channel_id") + return + } + + auditRec.AddEventPriorState(originalChannelBookmark) + + channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId) + if appErr != nil { + c.Err = appErr + return + } + + switch channel.Type { + case model.ChannelTypeOpen: + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPublicChannel) { + c.SetPermissionError(model.PermissionEditBookmarkPublicChannel) + return + } + + case model.ChannelTypePrivate: + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPrivateChannel) { + c.SetPermissionError(model.PermissionEditBookmarkPrivateChannel) + return + } + + case model.ChannelTypeGroup, model.ChannelTypeDirect: + // Any member of DM/GMs but guests can manage channel bookmarks + if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil { + c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden) + return + } + + user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) + if gAppErr != nil { + c.Err = gAppErr + return + } + + if user.IsGuest() { + c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden) + return + } + + default: + c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.forbidden.app_error", nil, "", http.StatusForbidden) + return + } + + patchedBookmark.Patch(patch) + updateChannelBookmarkResponse, appErr := c.App.UpdateChannelBookmark(c.AppContext, patchedBookmark, connectionID) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(updateChannelBookmarkResponse) + auditRec.AddEventObjectType("updateChannelBookmarkResponse") + c.LogAudit("") + + if err := json.NewEncoder(w).Encode(updateChannelBookmarkResponse); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Channels().License() == nil { + c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented) + return + } + + connectionID := r.Header.Get(model.ConnectionId) + + c.RequireChannelId() + if c.Err != nil { + return + } + + var newSortOrder int64 + if err := json.NewDecoder(r.Body).Decode(&newSortOrder); err != nil { + c.SetInvalidParamWithErr("channelBookmarkSortOrder", err) + return + } + + if newSortOrder < 0 { + c.SetInvalidParam("channelBookmarkSortOrder") + return + } + + auditRec := c.MakeAuditRecord("updateChannelBookmarkSortOrder", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "id", c.Params.ChannelBookmarkId) + + channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId) + if appErr != nil { + c.Err = appErr + return + } + + switch channel.Type { + case model.ChannelTypeOpen: + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPublicChannel) { + c.SetPermissionError(model.PermissionOrderBookmarkPublicChannel) + return + } + + case model.ChannelTypePrivate: + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPrivateChannel) { + c.SetPermissionError(model.PermissionOrderBookmarkPrivateChannel) + return + } + + case model.ChannelTypeGroup, model.ChannelTypeDirect: + // Any member of DM/GMs but guests can manage channel bookmarks + if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil { + c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden) + return + } + + user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) + if gAppErr != nil { + c.Err = gAppErr + return + } + + if user.IsGuest() { + c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden) + return + } + + default: + c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.forbidden.app_error", nil, "", http.StatusForbidden) + return + } + + bookmarks, appErr := c.App.UpdateChannelBookmarkSortOrder(c.Params.ChannelBookmarkId, c.Params.ChannelId, newSortOrder, connectionID) + if appErr != nil { + c.Err = appErr + return + } + + for _, b := range bookmarks { + if b.Id == c.Params.ChannelBookmarkId { + auditRec.AddEventResultState(b) + auditRec.AddEventObjectType("channelBookmarkWithFileInfo") + break + } + } + auditRec.Success() + c.LogAudit("") + + if err := json.NewEncoder(w).Encode(bookmarks); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Channels().License() == nil { + c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented) + return + } + + connectionID := r.Header.Get(model.ConnectionId) + + c.RequireChannelId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord("deleteChannelBookmark", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "id", c.Params.ChannelBookmarkId) + + channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId) + if appErr != nil { + c.Err = appErr + return + } + + switch channel.Type { + case model.ChannelTypeOpen: + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPublicChannel) { + c.SetPermissionError(model.PermissionDeleteBookmarkPublicChannel) + return + } + + case model.ChannelTypePrivate: + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPrivateChannel) { + c.SetPermissionError(model.PermissionDeleteBookmarkPrivateChannel) + return + } + + case model.ChannelTypeGroup, model.ChannelTypeDirect: + // Any member of DM/GMs but guests can manage channel bookmarks + if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil { + c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden) + return + } + + user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) + if gAppErr != nil { + c.Err = gAppErr + return + } + + if user.IsGuest() { + c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden) + return + } + + default: + c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.forbidden.app_error", nil, "", http.StatusForbidden) + return + } + + oldBookmark, obErr := c.App.GetBookmark(c.Params.ChannelBookmarkId, false) + if obErr != nil { + c.Err = obErr + return + } + + // The channel bookmark should belong to the same channel specified in the URL + if oldBookmark.ChannelId != c.Params.ChannelId { + c.SetInvalidParam("channel_id") + return + } + auditRec.AddEventPriorState(oldBookmark) + + bookmark, appErr := c.App.DeleteChannelBookmark(c.Params.ChannelBookmarkId, connectionID) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(bookmark) + c.LogAudit("bookmark=" + bookmark.DisplayName) + + if err := json.NewEncoder(w).Encode(bookmark); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func listChannelBookmarksForChannel(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Channels().License() == nil { + c.Err = model.NewAppError("listChannelBookmarksForChannel", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented) + return + } + + c.RequireChannelId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannelContent) { + c.SetPermissionError(model.PermissionReadChannelContent) + return + } + + bookmarks, appErr := c.App.GetChannelBookmarks(c.Params.ChannelId, c.Params.BookmarksSince) + if appErr != nil { + c.Err = appErr + return + } + + if err := json.NewEncoder(w).Encode(bookmarks); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} diff --git a/server/channels/api4/channel_bookmark_test.go b/server/channels/api4/channel_bookmark_test.go new file mode 100644 index 0000000000..68276871ba --- /dev/null +++ b/server/channels/api4/channel_bookmark_test.go @@ -0,0 +1,1551 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "context" + "encoding/json" + "net/http" + "os" + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestCreateChannelBookmark(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ChannelBookmarks", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ChannelBookmarks") + + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.SetPhase2PermissionsMigrationStatus(true) + + t.Run("should not work without a license", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + _, _, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + CheckErrorID(t, err, "api.channel.bookmark.channel_bookmark.license.error") + }) + + // enable guest accounts and add the license + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true }) + th.App.Srv().SetLicense(model.NewTestLicense()) + + guest, guestClient := th.CreateGuestAndClient() + + t.Run("a user should be able to create a channel bookmark in a public channel", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + require.Equal(t, cb.DisplayName, channelBookmark.DisplayName) + }) + + t.Run("a user should be able to create a channel bookmark in a private channel", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicPrivateChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + require.Equal(t, cb.DisplayName, channelBookmark.DisplayName) + }) + + t.Run("without the necessary permission on public channels, the creation should fail", func(t *testing.T) { + th.RemovePermissionFromRole(model.PermissionAddBookmarkPublicChannel.Id, model.ChannelUserRoleId) + defer th.AddPermissionToRole(model.PermissionAddBookmarkPublicChannel.Id, model.ChannelUserRoleId) + + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, cb) + }) + + t.Run("without the necessary permission on private channels, the creation should fail", func(t *testing.T) { + th.RemovePermissionFromRole(model.PermissionAddBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + defer th.AddPermissionToRole(model.PermissionAddBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicPrivateChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, cb) + }) + + t.Run("bookmark creation should not work in a moderated channel", func(t *testing.T) { + // moderate the channel to restrict bookmarks for members + manageBookmarks := model.ChannelModeratedPermissions[4] + th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, false) + defer th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, true) + + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, cb) + }) + + t.Run("a guest user should not be able to create a channel bookmark", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + // test in public channel + cb, resp, err := guestClient.CreateChannelBookmark(context.Background(), channelBookmark) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, cb) + + // test in private channel + channelBookmark.ChannelId = th.BasicPrivateChannel.Id + cb, resp, err = guestClient.CreateChannelBookmark(context.Background(), channelBookmark) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, cb) + }) + + t.Run("a user should always be able to create channel bookmarks on DMs and GMs", func(t *testing.T) { + // this should work independently of the permissions applied + th.RemovePermissionFromRole(model.PermissionAddBookmarkPublicChannel.Id, model.ChannelUserRoleId) + th.RemovePermissionFromRole(model.PermissionAddBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + defer func() { + th.AddPermissionToRole(model.PermissionAddBookmarkPublicChannel.Id, model.ChannelUserRoleId) + th.AddPermissionToRole(model.PermissionAddBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + }() + + // DM + dm, dmErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, guest.Id) + require.Nil(t, dmErr) + + channelBookmark := &model.ChannelBookmark{ + ChannelId: dm.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + // GM + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.SystemAdminUser.Id, guest.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + channelBookmark.ChannelId = gm.Id + cb, resp, err = th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + }) + + t.Run("a guest should not be able to create channel bookmarks on DMs and GMs", func(t *testing.T) { + // DM + dm, dmErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, guest.Id) + require.Nil(t, dmErr) + + channelBookmark := &model.ChannelBookmark{ + ChannelId: dm.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := guestClient.CreateChannelBookmark(context.Background(), channelBookmark) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, cb) + + // GM + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.SystemAdminUser.Id, guest.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + channelBookmark.ChannelId = gm.Id + cb, resp, err = guestClient.CreateChannelBookmark(context.Background(), channelBookmark) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, cb) + }) + + t.Run("a websockets event should be fired as part of creating a bookmark", func(t *testing.T) { + webSocketClient, err := th.CreateWebSocketClient() + require.NoError(t, err) + webSocketClient.Listen() + defer webSocketClient.Close() + + bookmark1 := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + // set the user for the session + originalSessionUserId := th.Context.Session().UserId + th.Context.Session().UserId = th.BasicUser.Id + defer func() { th.Context.Session().UserId = originalSessionUserId }() + + _, appErr := th.App.CreateChannelBookmark(th.Context, bookmark1, "") + require.Nil(t, appErr) + + var b model.ChannelBookmarkWithFileInfo + require.Eventuallyf(t, func() bool { + event := <-webSocketClient.EventChannel + if event.EventType() == model.WebsocketEventChannelBookmarkCreated { + err := json.Unmarshal([]byte(event.GetData()["bookmark"].(string)), &b) + require.NoError(t, err) + return true + } + return false + }, 2*time.Second, 250*time.Millisecond, "Websocket event for bookmark created not received", nil) + require.NotNil(t, b) + require.NotEmpty(t, b.Id) + }) +} + +func TestEditChannelBookmark(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ChannelBookmarks", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ChannelBookmarks") + + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.SetPhase2PermissionsMigrationStatus(true) + + t.Run("should not work without a license", func(t *testing.T) { + _, _, err := th.Client.UpdateChannelBookmark(context.Background(), th.BasicChannel.Id, model.NewId(), &model.ChannelBookmarkPatch{}) + CheckErrorID(t, err, "api.channel.bookmark.channel_bookmark.license.error") + }) + + // enable guest accounts and add the license + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true }) + th.App.Srv().SetLicense(model.NewTestLicense()) + + guest, guestClient := th.CreateGuestAndClient() + + t.Run("a user editing a channel bookmark in public and private channels", func(t *testing.T) { + testCases := []struct { + name string + channelId string + userClient *model.Client4 + removePermission string + expectedError bool + expectedStatus int + }{ + { + name: "public channel with permissions, should succeed", + channelId: th.BasicChannel.Id, + userClient: th.Client, + expectedError: false, + expectedStatus: http.StatusOK, + }, + { + name: "private channel with permissions, should succeed", + channelId: th.BasicPrivateChannel.Id, + userClient: th.Client, + expectedError: false, + expectedStatus: http.StatusOK, + }, + { + name: "public channel without permissions, should fail", + channelId: th.BasicChannel.Id, + userClient: th.Client, + removePermission: model.PermissionEditBookmarkPublicChannel.Id, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "private channel without permissions, should fail", + channelId: th.BasicPrivateChannel.Id, + userClient: th.Client, + removePermission: model.PermissionEditBookmarkPrivateChannel.Id, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "guest user in a public channel, should fail", + channelId: th.BasicChannel.Id, + userClient: guestClient, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "guest user in a private channel, should fail", + channelId: th.BasicPrivateChannel.Id, + userClient: guestClient, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.removePermission != "" { + th.RemovePermissionFromRole(tc.removePermission, model.ChannelUserRoleId) + defer th.AddPermissionToRole(tc.removePermission, model.ChannelUserRoleId) + } + + channelBookmark := &model.ChannelBookmark{ + ChannelId: tc.channelId, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + patch := &model.ChannelBookmarkPatch{ + DisplayName: model.NewString("Edited bookmark test"), + LinkUrl: model.NewString("http://edited.url"), + } + + ucb, resp, err := tc.userClient.UpdateChannelBookmark(context.Background(), cb.ChannelId, cb.Id, patch) + if tc.expectedError { + require.Error(t, err) + require.Nil(t, ucb) + } else { + require.NoError(t, err) + require.Nil(t, ucb.Deleted) + require.NotNil(t, ucb.Updated) + require.Equal(t, "Edited bookmark test", ucb.Updated.DisplayName) + require.Equal(t, "http://edited.url", ucb.Updated.LinkUrl) + } + checkHTTPStatus(t, resp, tc.expectedStatus) + }) + } + }) + + t.Run("bookmark editing should not work in a moderated channel", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + // moderate the channel to restrict bookmarks for members + manageBookmarks := model.ChannelModeratedPermissions[4] + th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, false) + defer th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, true) + + // try to patch the channel bookmark + patch := &model.ChannelBookmarkPatch{ + DisplayName: model.NewString("Edited bookmark test"), + LinkUrl: model.NewString("http://edited.url"), + } + + ucb, resp, err := th.Client.UpdateChannelBookmark(context.Background(), cb.ChannelId, cb.Id, patch) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, ucb) + }) + + t.Run("trying to edit a nonexistent bookmark should fail", func(t *testing.T) { + patch := &model.ChannelBookmarkPatch{ + DisplayName: model.NewString("Edited bookmark test"), + LinkUrl: model.NewString("http://edited.url"), + } + + ucb, resp, err := th.Client.UpdateChannelBookmark(context.Background(), th.BasicChannel.Id, model.NewId(), patch) + require.Error(t, err) + CheckNotFoundStatus(t, resp) + require.Nil(t, ucb) + }) + + t.Run("trying to edit an already deleted bookmark should fail", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + CheckCreatedStatus(t, resp) + require.NoError(t, err) + require.NotNil(t, cb) + + _, appErr := th.App.DeleteChannelBookmark(cb.Id, "") + require.Nil(t, appErr) + + patch := &model.ChannelBookmarkPatch{ + DisplayName: model.NewString("Edited bookmark test"), + LinkUrl: model.NewString("http://edited.url"), + } + + ucb, resp, err := th.Client.UpdateChannelBookmark(context.Background(), cb.ChannelId, cb.Id, patch) + require.Error(t, err) + CheckNotFoundStatus(t, resp) + require.Nil(t, ucb) + }) + + t.Run("a user should always be able to edit channel bookmarks on DMs and GMs", func(t *testing.T) { + // this should work independently of the permissions applied + th.RemovePermissionFromRole(model.PermissionEditBookmarkPublicChannel.Id, model.ChannelUserRoleId) + th.RemovePermissionFromRole(model.PermissionEditBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + defer func() { + th.AddPermissionToRole(model.PermissionEditBookmarkPublicChannel.Id, model.ChannelUserRoleId) + th.AddPermissionToRole(model.PermissionEditBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + }() + + // DM + dm, dmErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, guest.Id) + require.Nil(t, dmErr) + + channelBookmark := &model.ChannelBookmark{ + ChannelId: dm.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + CheckCreatedStatus(t, resp) + require.NoError(t, err) + require.NotNil(t, cb) + + patch := &model.ChannelBookmarkPatch{ + DisplayName: model.NewString("Edited bookmark test"), + LinkUrl: model.NewString("http://edited.url"), + } + + ucb, resp, err := th.Client.UpdateChannelBookmark(context.Background(), cb.ChannelId, cb.Id, patch) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Nil(t, ucb.Deleted) + require.NotNil(t, ucb.Updated) + require.Equal(t, "Edited bookmark test", ucb.Updated.DisplayName) + require.Equal(t, "http://edited.url", ucb.Updated.LinkUrl) + + // GM + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.SystemAdminUser.Id, guest.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + channelBookmark.ChannelId = gm.Id + gcb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, gcb) + + gucb, resp, err := th.Client.UpdateChannelBookmark(context.Background(), gcb.ChannelId, gcb.Id, patch) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Nil(t, gucb.Deleted) + require.NotNil(t, gucb.Updated) + require.Equal(t, "Edited bookmark test", gucb.Updated.DisplayName) + require.Equal(t, "http://edited.url", gucb.Updated.LinkUrl) + }) + + t.Run("a guest should not be able to edit channel bookmarks on DMs and GMs", func(t *testing.T) { + // DM + dm, dmErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, guest.Id) + require.Nil(t, dmErr) + + channelBookmark := &model.ChannelBookmark{ + ChannelId: dm.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + patch := &model.ChannelBookmarkPatch{ + DisplayName: model.NewString("Edited bookmark test"), + LinkUrl: model.NewString("http://edited.url"), + } + + ucb, resp, err := guestClient.UpdateChannelBookmark(context.Background(), cb.ChannelId, cb.Id, patch) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, ucb) + + // GM + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.SystemAdminUser.Id, guest.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + channelBookmark.ChannelId = gm.Id + gcb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + gucb, resp, err := guestClient.UpdateChannelBookmark(context.Background(), gcb.ChannelId, gcb.Id, patch) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, gucb) + }) + + t.Run("a user should be able to edit another user's bookmark", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + patch := &model.ChannelBookmarkPatch{ + DisplayName: model.NewString("Edited bookmark test"), + LinkUrl: model.NewString("http://edited.url"), + } + + // create a client for basic user 2 + client2 := th.CreateClient() + _, _, lErr := client2.Login(context.Background(), th.BasicUser2.Username, "Pa$$word11") + require.NoError(t, lErr) + + ucb, resp, err := client2.UpdateChannelBookmark(context.Background(), cb.ChannelId, cb.Id, patch) + require.NoError(t, err) + CheckOKStatus(t, resp) + + // Deleted should contain old channel bookmark + require.NotNil(t, ucb.Deleted) + require.Equal(t, cb.DisplayName, ucb.Deleted.DisplayName) + require.Equal(t, cb.LinkUrl, ucb.Deleted.LinkUrl) + require.Equal(t, th.BasicUser.Id, ucb.Deleted.OwnerId) + + // Updated should contain the new channel bookmark + require.NotNil(t, ucb.Updated) + require.Equal(t, *patch.DisplayName, ucb.Updated.DisplayName) + require.Equal(t, *patch.LinkUrl, ucb.Updated.LinkUrl) + require.Equal(t, th.BasicUser2.Id, ucb.Updated.OwnerId) + }) + + t.Run("a websockets event should be fired as part of editing a bookmark", func(t *testing.T) { + webSocketClient, err := th.CreateWebSocketClient() + require.NoError(t, err) + webSocketClient.Listen() + defer webSocketClient.Close() + + bookmark1 := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + // set the user for the session + originalSessionUserId := th.Context.Session().UserId + th.Context.Session().UserId = th.BasicUser.Id + defer func() { th.Context.Session().UserId = originalSessionUserId }() + + cb, appErr := th.App.CreateChannelBookmark(th.Context, bookmark1, "") + require.Nil(t, appErr) + require.NotNil(t, cb) + + patch := &model.ChannelBookmarkPatch{DisplayName: model.NewString("Edited bookmark test")} + _, resp, err := th.Client.UpdateChannelBookmark(context.Background(), cb.ChannelId, cb.Id, patch) + require.NoError(t, err) + CheckOKStatus(t, resp) + + var ucb model.UpdateChannelBookmarkResponse + require.Eventuallyf(t, func() bool { + event := <-webSocketClient.EventChannel + if event.EventType() == model.WebsocketEventChannelBookmarkUpdated { + err := json.Unmarshal([]byte(event.GetData()["bookmarks"].(string)), &ucb) + require.NoError(t, err) + return true + } + return false + }, 2*time.Second, 250*time.Millisecond, "Websocket event for bookmark edited not received", nil) + + require.NotNil(t, ucb) + require.NotEmpty(t, ucb.Updated) + require.Equal(t, "Edited bookmark test", ucb.Updated.DisplayName) + }) +} + +func TestUpdateChannelBookmarkSortOrder(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ChannelBookmarks", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ChannelBookmarks") + + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.SetPhase2PermissionsMigrationStatus(true) + + createBookmark := func(name, channelId string) *model.ChannelBookmarkWithFileInfo { + b := &model.ChannelBookmark{ + ChannelId: channelId, + DisplayName: name, + Type: model.ChannelBookmarkLink, + LinkUrl: "https://sample.com", + } + + nb, appErr := th.App.CreateChannelBookmark(th.Context, b, "") + require.Nil(t, appErr) + return nb + } + + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + + publicBookmark1 := createBookmark("one", th.BasicChannel.Id) + publicBookmark2 := createBookmark("two", th.BasicChannel.Id) + publicBookmark3 := createBookmark("three", th.BasicChannel.Id) + _ = createBookmark("four", th.BasicChannel.Id) + + privateBookmark1 := createBookmark("one", th.BasicPrivateChannel.Id) + privateBookmark2 := createBookmark("two", th.BasicPrivateChannel.Id) + _ = createBookmark("three", th.BasicPrivateChannel.Id) + privateBookmark4 := createBookmark("four", th.BasicPrivateChannel.Id) + + t.Run("should not work without a license", func(t *testing.T) { + _, _, err := th.Client.UpdateChannelBookmarkSortOrder(context.Background(), th.BasicChannel.Id, model.NewId(), 1) + CheckErrorID(t, err, "api.channel.bookmark.channel_bookmark.license.error") + }) + + // enable guest accounts and add the license + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true }) + th.App.Srv().SetLicense(model.NewTestLicense()) + + guest, guestClient := th.CreateGuestAndClient() + + t.Run("a user updating a bookmark's order in public and private channels", func(t *testing.T) { + testCases := []struct { + name string + channelId string + bookmarkId string + sortOrder int64 + userClient *model.Client4 + removePermission string + expectedError bool + expectedStatus int + }{ + { + name: "public channel with permissions, should succeed", + channelId: th.BasicChannel.Id, + bookmarkId: publicBookmark2.Id, + sortOrder: 3, + userClient: th.Client, + expectedStatus: http.StatusOK, + }, + { + name: "private channel with permissions, should succeed", + channelId: th.BasicPrivateChannel.Id, + bookmarkId: privateBookmark1.Id, + sortOrder: 3, + userClient: th.Client, + expectedStatus: http.StatusOK, + }, + { + name: "public channel without permissions, should fail", + channelId: th.BasicChannel.Id, + bookmarkId: publicBookmark1.Id, + sortOrder: 3, + userClient: th.Client, + removePermission: model.PermissionOrderBookmarkPublicChannel.Id, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "private channel without permissions, should fail", + channelId: th.BasicPrivateChannel.Id, + bookmarkId: privateBookmark2.Id, + sortOrder: 1, + userClient: th.Client, + removePermission: model.PermissionOrderBookmarkPrivateChannel.Id, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "guest user in a public channel, should fail", + channelId: th.BasicChannel.Id, + bookmarkId: publicBookmark3.Id, + sortOrder: 2, + userClient: guestClient, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "guest user in a private channel, should fail", + channelId: th.BasicPrivateChannel.Id, + bookmarkId: privateBookmark4.Id, + sortOrder: 2, + userClient: guestClient, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "public channel with permissions, setting order to a negative number, should fail", + channelId: th.BasicChannel.Id, + bookmarkId: publicBookmark2.Id, + sortOrder: -1, + userClient: th.Client, + expectedError: true, + expectedStatus: http.StatusBadRequest, + }, + { + name: "public channel with permissions, setting order to a number greater than the amount of bookmarks of the channel, should fail", + channelId: th.BasicChannel.Id, + bookmarkId: publicBookmark2.Id, + sortOrder: 300, + userClient: th.Client, + expectedError: true, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.removePermission != "" { + th.RemovePermissionFromRole(tc.removePermission, model.ChannelUserRoleId) + defer th.AddPermissionToRole(tc.removePermission, model.ChannelUserRoleId) + } + + // first we capture and later restore original bookmark's sort order + originalBookmark, appErr := th.App.GetBookmark(tc.bookmarkId, false) + require.Nil(t, appErr) + defer func() { + th.App.UpdateChannelBookmarkSortOrder(originalBookmark.Id, originalBookmark.ChannelId, originalBookmark.SortOrder, "") + }() + + bookmarks, resp, err := tc.userClient.UpdateChannelBookmarkSortOrder(context.Background(), tc.channelId, tc.bookmarkId, tc.sortOrder) + if tc.expectedError { + require.Error(t, err) + require.Nil(t, bookmarks) + } else { + require.NoError(t, err) + require.Len(t, bookmarks, 4) + + // find and compare bookmark's new sort order + var bookmark *model.ChannelBookmarkWithFileInfo + for _, b := range bookmarks { + if b.Id == tc.bookmarkId { + bookmark = b + break + } + } + require.NotNil(t, bookmark, "updated bookmark should be in the client's response") + require.Equal(t, tc.sortOrder, bookmark.SortOrder) + } + checkHTTPStatus(t, resp, tc.expectedStatus) + }) + } + }) + + t.Run("bookmark ordering should not work in a moderated channel", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + // moderate the channel to restrict bookmarks for members + manageBookmarks := model.ChannelModeratedPermissions[4] + th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, false) + defer th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, true) + + // try to update the channel bookmark's order + bookmarks, resp, err := th.Client.UpdateChannelBookmarkSortOrder(context.Background(), cb.ChannelId, cb.Id, 0) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, bookmarks) + }) + + t.Run("trying to update the order of a nonexistent bookmark should fail", func(t *testing.T) { + bookmarks, resp, err := th.Client.UpdateChannelBookmarkSortOrder(context.Background(), th.BasicChannel.Id, model.NewId(), 1) + require.Error(t, err) + CheckNotFoundStatus(t, resp) + require.Nil(t, bookmarks) + }) + + t.Run("trying to update the order of an already deleted bookmark should fail", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + CheckCreatedStatus(t, resp) + require.NoError(t, err) + require.NotNil(t, cb) + + _, appErr := th.App.DeleteChannelBookmark(cb.Id, "") + require.Nil(t, appErr) + + bookmarks, resp, err := th.Client.UpdateChannelBookmarkSortOrder(context.Background(), th.BasicChannel.Id, cb.Id, 1) + require.Error(t, err) + CheckNotFoundStatus(t, resp) + require.Nil(t, bookmarks) + }) + + t.Run("a user should always be able to update the channel bookmarks sort order on DMs and GMs", func(t *testing.T) { + // this should work independently of the permissions applied + th.RemovePermissionFromRole(model.PermissionOrderBookmarkPublicChannel.Id, model.ChannelUserRoleId) + th.RemovePermissionFromRole(model.PermissionOrderBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + defer func() { + th.AddPermissionToRole(model.PermissionOrderBookmarkPublicChannel.Id, model.ChannelUserRoleId) + th.AddPermissionToRole(model.PermissionOrderBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + }() + + // DM + dm, dmErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, guest.Id) + require.Nil(t, dmErr) + + dmBookmark1 := createBookmark("one", dm.Id) + dmBookmark2 := createBookmark("two", dm.Id) + + bookmarks, resp, err := th.Client.UpdateChannelBookmarkSortOrder(context.Background(), dm.Id, dmBookmark1.Id, 1) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Len(t, bookmarks, 2) + require.Equal(t, dmBookmark2.Id, bookmarks[0].Id) + require.Equal(t, int64(0), bookmarks[0].SortOrder) + require.Equal(t, dmBookmark1.Id, bookmarks[1].Id) + require.Equal(t, int64(1), bookmarks[1].SortOrder) + + // GM + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.SystemAdminUser.Id, guest.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + gmBookmark1 := createBookmark("one", gm.Id) + gmBookmark2 := createBookmark("two", gm.Id) + + bookmarks, resp, err = th.Client.UpdateChannelBookmarkSortOrder(context.Background(), gm.Id, gmBookmark2.Id, 0) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Len(t, bookmarks, 2) + require.Equal(t, gmBookmark2.Id, bookmarks[0].Id) + require.Equal(t, int64(0), bookmarks[0].SortOrder) + require.Equal(t, gmBookmark1.Id, bookmarks[1].Id) + require.Equal(t, int64(1), bookmarks[1].SortOrder) + }) + + t.Run("a guest should not be able to edit channel bookmarks sort order on DMs and GMs", func(t *testing.T) { + // DM + dm, dmErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, guest.Id) + require.Nil(t, dmErr) + + dmBookmark1 := createBookmark("one", dm.Id) + _ = createBookmark("two", dm.Id) + + bookmarks, resp, err := guestClient.UpdateChannelBookmarkSortOrder(context.Background(), dm.Id, dmBookmark1.Id, 1) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, bookmarks) + + // GM + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.SystemAdminUser.Id, guest.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + _ = createBookmark("one", gm.Id) + gmBookmark2 := createBookmark("two", gm.Id) + + bookmarks, resp, err = guestClient.UpdateChannelBookmarkSortOrder(context.Background(), gm.Id, gmBookmark2.Id, 0) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, bookmarks) + }) + + t.Run("a user should be able to edit another user's bookmark sort order", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + // create a client for basic user 2 + client2 := th.CreateClient() + _, _, lErr := client2.Login(context.Background(), th.BasicUser2.Username, "Pa$$word11") + require.NoError(t, lErr) + + bookmarks, resp, err := client2.UpdateChannelBookmarkSortOrder(context.Background(), th.BasicChannel.Id, cb.Id, 0) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.NotEmpty(t, bookmarks) + require.Equal(t, cb.Id, bookmarks[0].Id) + require.Equal(t, int64(0), bookmarks[0].SortOrder) + }) + + t.Run("a websockets event should be fired as part of editing a bookmark's sort order", func(t *testing.T) { + webSocketClient, err := th.CreateWebSocketClient() + require.NoError(t, err) + webSocketClient.Listen() + defer webSocketClient.Close() + + bookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + // set the user for the session + originalSessionUserId := th.Context.Session().UserId + th.Context.Session().UserId = th.BasicUser.Id + defer func() { th.Context.Session().UserId = originalSessionUserId }() + + cb, appErr := th.App.CreateChannelBookmark(th.Context, bookmark, "") + require.Nil(t, appErr) + require.NotNil(t, cb) + + bookmarks, resp, err := th.Client.UpdateChannelBookmarkSortOrder(context.Background(), th.BasicChannel.Id, cb.Id, 0) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.NotEmpty(t, bookmarks) + + var bl []*model.ChannelBookmarkWithFileInfo + require.Eventuallyf(t, func() bool { + event := <-webSocketClient.EventChannel + if event.EventType() == model.WebsocketEventChannelBookmarkSorted { + err := json.Unmarshal([]byte(event.GetData()["bookmarks"].(string)), &bl) + require.NoError(t, err) + return true + } + return false + }, 2*time.Second, 250*time.Millisecond, "Websocket event for bookmark sorted not received", nil) + + require.NotEmpty(t, bl) + require.Equal(t, cb.Id, bl[0].Id) + require.Equal(t, int64(0), bl[0].SortOrder) + }) +} + +func TestDeleteChannelBookmark(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ChannelBookmarks", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ChannelBookmarks") + + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.SetPhase2PermissionsMigrationStatus(true) + + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + + t.Run("should not work without a license", func(t *testing.T) { + _, _, err := th.Client.DeleteChannelBookmark(context.Background(), th.BasicChannel.Id, model.NewId()) + CheckErrorID(t, err, "api.channel.bookmark.channel_bookmark.license.error") + }) + + // enable guest accounts and add the license + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true }) + th.App.Srv().SetLicense(model.NewTestLicense()) + + guest, guestClient := th.CreateGuestAndClient() + + t.Run("a user deleting bookmarks in public and private channels", func(t *testing.T) { + testCases := []struct { + name string + channelId string + userClient *model.Client4 + removePermission string + expectedError bool + expectedStatus int + }{ + { + name: "public channel with permissions, should succeed", + channelId: th.BasicChannel.Id, + userClient: th.Client, + expectedStatus: http.StatusOK, + }, + { + name: "private channel with permissions, should succeed", + channelId: th.BasicPrivateChannel.Id, + userClient: th.Client, + expectedStatus: http.StatusOK, + }, + { + name: "public channel without permissions, should fail", + channelId: th.BasicChannel.Id, + userClient: th.Client, + removePermission: model.PermissionDeleteBookmarkPublicChannel.Id, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "private channel without permissions, should fail", + channelId: th.BasicPrivateChannel.Id, + userClient: th.Client, + removePermission: model.PermissionDeleteBookmarkPrivateChannel.Id, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "guest user in a public channel, should fail", + channelId: th.BasicChannel.Id, + userClient: guestClient, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "guest user in a private channel, should fail", + channelId: th.BasicPrivateChannel.Id, + userClient: guestClient, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.removePermission != "" { + th.RemovePermissionFromRole(tc.removePermission, model.ChannelUserRoleId) + defer th.AddPermissionToRole(tc.removePermission, model.ChannelUserRoleId) + } + + // first we create a bookmark for the test case channel + bookmark := &model.ChannelBookmark{ + ChannelId: tc.channelId, + DisplayName: "Bookmark", + Type: model.ChannelBookmarkLink, + LinkUrl: "https://sample.com", + } + + cb, appErr := th.App.CreateChannelBookmark(th.Context, bookmark, "") + require.Nil(t, appErr) + require.NotNil(t, cb) + + // then we try to delete with the parameters of the test + b, resp, err := tc.userClient.DeleteChannelBookmark(context.Background(), tc.channelId, cb.Id) + if tc.expectedError { + require.Error(t, err) + require.Nil(t, b) + } else { + require.NoError(t, err) + require.Equal(t, cb.Id, b.Id) + } + checkHTTPStatus(t, resp, tc.expectedStatus) + }) + } + }) + + t.Run("bookmark deletion should not work in a moderated channel", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + // moderate the channel to restrict bookmarks for members + manageBookmarks := model.ChannelModeratedPermissions[4] + th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, false) + defer th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, true) + + // try to delete the channel bookmark's order + bookmarks, resp, err := th.Client.DeleteChannelBookmark(context.Background(), cb.ChannelId, cb.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, bookmarks) + }) + + t.Run("trying to delete a nonexistent bookmark should fail", func(t *testing.T) { + bookmarks, resp, err := th.Client.DeleteChannelBookmark(context.Background(), th.BasicChannel.Id, model.NewId()) + require.Error(t, err) + CheckNotFoundStatus(t, resp) + require.Nil(t, bookmarks) + }) + + t.Run("trying to delete an already deleted bookmark should fail", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + CheckCreatedStatus(t, resp) + require.NoError(t, err) + require.NotNil(t, cb) + + _, appErr := th.App.DeleteChannelBookmark(cb.Id, "") + require.Nil(t, appErr) + + bookmarks, resp, err := th.Client.DeleteChannelBookmark(context.Background(), th.BasicChannel.Id, cb.Id) + require.Error(t, err) + CheckNotFoundStatus(t, resp) + require.Nil(t, bookmarks) + }) + + t.Run("a user should always be able to delete the channel bookmarks on DMs and GMs", func(t *testing.T) { + // this should work independently of the permissions applied + th.RemovePermissionFromRole(model.PermissionDeleteBookmarkPublicChannel.Id, model.ChannelUserRoleId) + th.RemovePermissionFromRole(model.PermissionDeleteBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + defer func() { + th.AddPermissionToRole(model.PermissionDeleteBookmarkPublicChannel.Id, model.ChannelUserRoleId) + th.AddPermissionToRole(model.PermissionDeleteBookmarkPrivateChannel.Id, model.ChannelUserRoleId) + }() + + // DM + dm, dmErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, guest.Id) + require.Nil(t, dmErr) + + dmBookmark := &model.ChannelBookmark{ + ChannelId: dm.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + dmb, appErr := th.App.CreateChannelBookmark(th.Context, dmBookmark, "") + require.Nil(t, appErr) + + ddmb, resp, err := th.Client.DeleteChannelBookmark(context.Background(), dm.Id, dmb.Id) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Equal(t, dmb.Id, ddmb.Id) + require.NotZero(t, ddmb.DeleteAt) + + // GM + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.SystemAdminUser.Id, guest.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + gmBookmark := &model.ChannelBookmark{ + ChannelId: gm.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + gmb, appErr := th.App.CreateChannelBookmark(th.Context, gmBookmark, "") + require.Nil(t, appErr) + + dgmb, resp, err := th.Client.DeleteChannelBookmark(context.Background(), gm.Id, gmb.Id) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.Equal(t, gmb.Id, dgmb.Id) + require.NotZero(t, dgmb.DeleteAt) + }) + + t.Run("a guest should not be able to delete channel bookmarks on DMs and GMs", func(t *testing.T) { + // DM + dm, dmErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, guest.Id) + require.Nil(t, dmErr) + + dmBookmark := &model.ChannelBookmark{ + ChannelId: dm.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + dmb, appErr := th.App.CreateChannelBookmark(th.Context, dmBookmark, "") + require.Nil(t, appErr) + + ddmb, resp, err := guestClient.DeleteChannelBookmark(context.Background(), dm.Id, dmb.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, ddmb) + + // GM + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.SystemAdminUser.Id, guest.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + gmBookmark := &model.ChannelBookmark{ + ChannelId: gm.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + gmb, appErr := th.App.CreateChannelBookmark(th.Context, gmBookmark, "") + require.Nil(t, appErr) + + dgmb, resp, err := guestClient.DeleteChannelBookmark(context.Background(), gm.Id, gmb.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + require.Nil(t, dgmb) + }) + + t.Run("a user should be able to delete another user's bookmark", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + // create a client for basic user 2 + client2 := th.CreateClient() + _, _, lErr := client2.Login(context.Background(), th.BasicUser2.Username, "Pa$$word11") + require.NoError(t, lErr) + + dbm, resp, err := client2.DeleteChannelBookmark(context.Background(), th.BasicChannel.Id, cb.Id) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.NotEmpty(t, dbm) + require.Equal(t, cb.Id, dbm.Id) + require.NotZero(t, dbm.DeleteAt) + }) + + t.Run("a websockets event should be fired as part of deleting a bookmark", func(t *testing.T) { + webSocketClient, err := th.CreateWebSocketClient() + require.NoError(t, err) + webSocketClient.Listen() + defer webSocketClient.Close() + + bookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + // set the user for the session + originalSessionUserId := th.Context.Session().UserId + th.Context.Session().UserId = th.BasicUser.Id + defer func() { th.Context.Session().UserId = originalSessionUserId }() + + cb, appErr := th.App.CreateChannelBookmark(th.Context, bookmark, "") + require.Nil(t, appErr) + require.NotNil(t, cb) + + dbm, resp, err := th.Client.DeleteChannelBookmark(context.Background(), th.BasicChannel.Id, cb.Id) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.NotEmpty(t, dbm) + + var b *model.ChannelBookmarkWithFileInfo + require.Eventuallyf(t, func() bool { + event := <-webSocketClient.EventChannel + if event.EventType() == model.WebsocketEventChannelBookmarkDeleted { + err := json.Unmarshal([]byte(event.GetData()["bookmark"].(string)), &b) + require.NoError(t, err) + return true + } + return false + }, 2*time.Second, 250*time.Millisecond, "Websocket event for bookmark deleted not received", nil) + + require.NotEmpty(t, b) + require.Equal(t, cb.Id, b.Id) + require.NotEmpty(t, b.DeleteAt) + }) +} + +func TestListChannelBookmarksForChannel(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ChannelBookmarks", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ChannelBookmarks") + + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.SetPhase2PermissionsMigrationStatus(true) + + createBookmark := func(name, channelId string) *model.ChannelBookmarkWithFileInfo { + b := &model.ChannelBookmark{ + ChannelId: channelId, + DisplayName: name, + Type: model.ChannelBookmarkLink, + LinkUrl: "https://sample.com", + } + + nb, appErr := th.App.CreateChannelBookmark(th.Context, b, "") + require.Nil(t, appErr) + time.Sleep(1 * time.Millisecond) + return nb + } + + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + + t.Run("should not work without a license", func(t *testing.T) { + _, _, err := th.Client.DeleteChannelBookmark(context.Background(), th.BasicChannel.Id, model.NewId()) + CheckErrorID(t, err, "api.channel.bookmark.channel_bookmark.license.error") + }) + + // enable guest accounts and add the license + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.GuestAccountsSettings.Enable = true }) + th.App.Srv().SetLicense(model.NewTestLicense()) + + guest, guestClient := th.CreateGuestAndClient() + + publicBookmark1 := createBookmark("one", th.BasicChannel.Id) + publicBookmark2 := createBookmark("two", th.BasicChannel.Id) + publicBookmark3 := createBookmark("three", th.BasicChannel.Id) + publicBookmark4 := createBookmark("four", th.BasicChannel.Id) + _, dErr := th.App.DeleteChannelBookmark(publicBookmark1.Id, "") + require.Nil(t, dErr) + + privateBookmark1 := createBookmark("one", th.BasicPrivateChannel.Id) + privateBookmark2 := createBookmark("two", th.BasicPrivateChannel.Id) + privateBookmark3 := createBookmark("three", th.BasicPrivateChannel.Id) + privateBookmark4 := createBookmark("four", th.BasicPrivateChannel.Id) + _, dErr = th.App.DeleteChannelBookmark(privateBookmark1.Id, "") + require.Nil(t, dErr) + + // an open channel for which the guest is a member but the basic + // user is not + onlyGuestChannel := th.CreateChannelWithClient(th.SystemAdminClient, model.ChannelTypeOpen) + th.AddUserToChannel(guest, onlyGuestChannel) + guestBookmark := createBookmark("guest", onlyGuestChannel.Id) + + // DM + dm, dmErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, guest.Id) + require.Nil(t, dmErr) + dmBookmark := createBookmark("dm-one", dm.Id) + + // GM + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.SystemAdminUser.Id, guest.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + gmBookmark := createBookmark("gm-one", gm.Id) + + t.Run("a user listing bookmarks in public and private channels", func(t *testing.T) { + testCases := []struct { + name string + channelId string + since int64 + userClient *model.Client4 + expectedBookmarks []string + expectedError bool + expectedStatus int + }{ + { + name: "public channel without since, should retrieve all non deleted bookmarks", + channelId: th.BasicChannel.Id, + userClient: th.Client, + expectedBookmarks: []string{publicBookmark2.Id, publicBookmark3.Id, publicBookmark4.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "private channel without since, should retrieve all non deleted bookmarks", + channelId: th.BasicPrivateChannel.Id, + userClient: th.Client, + expectedBookmarks: []string{privateBookmark2.Id, privateBookmark3.Id, privateBookmark4.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "public channel with since set early, should retrieve all bookmarks include the deleted one", + channelId: th.BasicChannel.Id, + since: publicBookmark1.CreateAt, + userClient: th.Client, + expectedBookmarks: []string{publicBookmark1.Id, publicBookmark2.Id, publicBookmark3.Id, publicBookmark4.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "Private channel with since set early, should retrieve all bookmarks include the deleted one", + channelId: th.BasicPrivateChannel.Id, + since: privateBookmark1.CreateAt, + userClient: th.Client, + expectedBookmarks: []string{privateBookmark1.Id, privateBookmark2.Id, privateBookmark3.Id, privateBookmark4.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "public channel with since, should retrieve some of the bookmarks", + channelId: th.BasicChannel.Id, + since: publicBookmark3.CreateAt, + userClient: th.Client, + expectedBookmarks: []string{publicBookmark1.Id, publicBookmark3.Id, publicBookmark4.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "private channel with since, should retrieve some of the bookmarks", + channelId: th.BasicPrivateChannel.Id, + since: privateBookmark4.CreateAt, + userClient: th.Client, + expectedBookmarks: []string{privateBookmark1.Id, privateBookmark4.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "guest user, public channel without since, should retrieve all non deleted bookmarks", + channelId: th.BasicChannel.Id, + userClient: guestClient, + expectedBookmarks: []string{publicBookmark2.Id, publicBookmark3.Id, publicBookmark4.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "guest user, private channel without since, should retrieve all non deleted bookmarks", + channelId: th.BasicPrivateChannel.Id, + userClient: guestClient, + expectedBookmarks: []string{privateBookmark2.Id, privateBookmark3.Id, privateBookmark4.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "guest user, guest channel without since, should retrieve all non deleted bookmarks", + channelId: onlyGuestChannel.Id, + userClient: guestClient, + expectedBookmarks: []string{guestBookmark.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "normal user, guest channel without since, should fail as user is not a member", + channelId: onlyGuestChannel.Id, + userClient: th.Client, + expectedBookmarks: []string{}, + expectedError: true, + expectedStatus: http.StatusForbidden, + }, + { + name: "guest user, dm without since, should retrieve all non deleted bookmarks", + channelId: dm.Id, + userClient: guestClient, + expectedBookmarks: []string{dmBookmark.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "normal user, dm without since, should retrieve all non deleted bookmarks", + channelId: dm.Id, + userClient: th.Client, + expectedBookmarks: []string{dmBookmark.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "guest user, gm without since, should retrieve all non deleted bookmarks", + channelId: gm.Id, + userClient: guestClient, + expectedBookmarks: []string{gmBookmark.Id}, + expectedStatus: http.StatusOK, + }, + { + name: "normal user, gm without since, should retrieve all non deleted bookmarks", + channelId: gm.Id, + userClient: th.Client, + expectedBookmarks: []string{gmBookmark.Id}, + expectedStatus: http.StatusOK, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + bookmarks, resp, err := tc.userClient.ListChannelBookmarksForChannel(context.Background(), tc.channelId, tc.since) + if tc.expectedError { + require.Error(t, err) + require.Nil(t, bookmarks) + } else { + require.NoError(t, err) + + bookmarkIDs := make([]string, len(bookmarks)) + for i, b := range bookmarks { + bookmarkIDs[i] = b.Id + } + + require.ElementsMatch(t, tc.expectedBookmarks, bookmarkIDs) + } + checkHTTPStatus(t, resp, tc.expectedStatus) + }) + } + }) + + t.Run("bookmark listing should work in a moderated channel", func(t *testing.T) { + channelBookmark := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + cb, resp, err := th.Client.CreateChannelBookmark(context.Background(), channelBookmark) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, cb) + + // moderate the channel to restrict bookmarks for members + manageBookmarks := model.ChannelModeratedPermissions[4] + th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, false) + defer th.PatchChannelModerationsForMembers(th.BasicChannel.Id, manageBookmarks, true) + + // try to list existing channel bookmarks + bookmarks, resp, err := th.Client.ListChannelBookmarksForChannel(context.Background(), th.BasicChannel.Id, 0) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.NotEmpty(t, bookmarks) + }) +} diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 77f81b707b..06ee9f90a1 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -4230,9 +4230,9 @@ func TestGetChannelModerations(t *testing.T) { t.Run("Returns default moderations with default roles", func(t *testing.T) { moderations, _, err := th.SystemAdminClient.GetChannelModerations(context.Background(), channel.Id, "") require.NoError(t, err) - require.Equal(t, len(moderations), 4) + require.Equal(t, len(moderations), 5) for _, moderation := range moderations { - if moderation.Name == "manage_members" { + if moderation.Name == "manage_members" || moderation.Name == "manage_bookmarks" { require.Empty(t, moderation.Roles.Guests) } else { require.Equal(t, moderation.Roles.Guests.Value, true) @@ -4320,7 +4320,7 @@ func TestGetChannelModerations(t *testing.T) { require.Nil(t, appErr) th.RemovePermissionFromRole(model.PermissionManagePublicChannelMembers.Id, scheme.DefaultChannelUserRole) - defer th.AddPermissionToRole(model.PermissionCreatePost.Id, scheme.DefaultChannelUserRole) + defer th.AddPermissionToRole(model.PermissionManagePublicChannelMembers.Id, scheme.DefaultChannelUserRole) // public channel does not have the permission moderations, _, err := th.SystemAdminClient.GetChannelModerations(context.Background(), channel.Id, "") @@ -4341,6 +4341,48 @@ func TestGetChannelModerations(t *testing.T) { } }) + t.Run("Returns the correct value for manage_bookmarks depending on whether the channel is public or private", func(t *testing.T) { + scheme := th.SetupTeamScheme() + team.SchemeId = &scheme.Id + _, appErr := th.App.UpdateTeamScheme(team) + require.Nil(t, appErr) + + bookmarkPublicPermissions := []string{ + model.PermissionAddBookmarkPublicChannel.Id, + model.PermissionEditBookmarkPublicChannel.Id, + model.PermissionDeleteBookmarkPublicChannel.Id, + model.PermissionOrderBookmarkPublicChannel.Id, + } + + for _, p := range bookmarkPublicPermissions { + th.RemovePermissionFromRole(p, scheme.DefaultChannelUserRole) + } + + defer func() { + for _, p := range bookmarkPublicPermissions { + th.AddPermissionToRole(p, scheme.DefaultChannelUserRole) + } + }() + + // public channel does not have the permissions + moderations, _, err := th.SystemAdminClient.GetChannelModerations(context.Background(), channel.Id, "") + require.NoError(t, err) + for _, moderation := range moderations { + if moderation.Name == "manage_bookmarks" { + require.Equal(t, moderation.Roles.Members.Value, false) + } + } + + // private channel does have the permissions + moderations, _, err = th.SystemAdminClient.GetChannelModerations(context.Background(), th.BasicPrivateChannel.Id, "") + require.NoError(t, err) + for _, moderation := range moderations { + if moderation.Name == "manage_bookmarks" { + require.Equal(t, moderation.Roles.Members.Value, true) + } + } + }) + t.Run("Does not return an error if the team scheme has a blank DefaultChannelGuestRole field", func(t *testing.T) { scheme := th.SetupTeamScheme() scheme.DefaultChannelGuestRole = "" @@ -4405,9 +4447,9 @@ func TestPatchChannelModerations(t *testing.T) { t.Run("Returns default moderations with empty patch", func(t *testing.T) { moderations, _, err := th.SystemAdminClient.PatchChannelModerations(context.Background(), channel.Id, emptyPatch) require.NoError(t, err) - require.Equal(t, len(moderations), 4) + require.Equal(t, len(moderations), 5) for _, moderation := range moderations { - if moderation.Name == "manage_members" { + if moderation.Name == "manage_members" || moderation.Name == "manage_bookmarks" { require.Empty(t, moderation.Roles.Guests) } else { require.Equal(t, moderation.Roles.Guests.Value, true) @@ -4431,9 +4473,9 @@ func TestPatchChannelModerations(t *testing.T) { moderations, _, err := th.SystemAdminClient.PatchChannelModerations(context.Background(), channel.Id, patch) require.NoError(t, err) - require.Equal(t, len(moderations), 4) + require.Equal(t, len(moderations), 5) for _, moderation := range moderations { - if moderation.Name == "manage_members" { + if moderation.Name == "manage_members" || moderation.Name == "manage_bookmarks" { require.Empty(t, moderation.Roles.Guests) } else { require.Equal(t, moderation.Roles.Guests.Value, true) @@ -4468,9 +4510,9 @@ func TestPatchChannelModerations(t *testing.T) { moderations, _, err := th.SystemAdminClient.PatchChannelModerations(context.Background(), channel.Id, patch) require.NoError(t, err) - require.Equal(t, len(moderations), 4) + require.Equal(t, len(moderations), 5) for _, moderation := range moderations { - if moderation.Name == "manage_members" { + if moderation.Name == "manage_members" || moderation.Name == "manage_bookmarks" { require.Empty(t, moderation.Roles.Guests) } else { require.Equal(t, moderation.Roles.Guests.Value, true) @@ -4523,9 +4565,9 @@ func TestPatchChannelModerations(t *testing.T) { moderations, _, err := th.SystemAdminClient.PatchChannelModerations(context.Background(), channel.Id, emptyPatch) require.NoError(t, err) - require.Equal(t, len(moderations), 4) + require.Equal(t, len(moderations), 5) for _, moderation := range moderations { - if moderation.Name == "manage_members" { + if moderation.Name == "manage_members" || moderation.Name == "manage_bookmarks" { require.Empty(t, moderation.Roles.Guests) } else { require.Equal(t, moderation.Roles.Guests.Value, false) @@ -4545,9 +4587,9 @@ func TestPatchChannelModerations(t *testing.T) { moderations, _, err = th.SystemAdminClient.PatchChannelModerations(context.Background(), channel.Id, patch) require.NoError(t, err) - require.Equal(t, len(moderations), 4) + require.Equal(t, len(moderations), 5) for _, moderation := range moderations { - if moderation.Name == "manage_members" { + if moderation.Name == "manage_members" || moderation.Name == "manage_bookmarks" { require.Empty(t, moderation.Roles.Guests) } else { require.Equal(t, moderation.Roles.Guests.Value, false) diff --git a/server/channels/api4/file.go b/server/channels/api4/file.go index 514bbf2313..59677273c0 100644 --- a/server/channels/api4/file.go +++ b/server/channels/api4/file.go @@ -150,9 +150,15 @@ func uploadFileSimple(c *Context, r *http.Request, timestamp time.Time) *model.F clientId := r.Form.Get("client_id") audit.AddEventParameter(auditRec, "client_id", clientId) + creatorId := c.AppContext.Session().UserId + if isBookmark, err := strconv.ParseBool(r.URL.Query().Get(model.BookmarkFileOwner)); err == nil && isBookmark { + creatorId = model.BookmarkFileOwner + audit.AddEventParameter(auditRec, model.BookmarkFileOwner, true) + } + info, appErr := c.App.UploadFileX(c.AppContext, c.Params.ChannelId, c.Params.Filename, r.Body, app.UploadFileSetTeamId(FileTeamId), - app.UploadFileSetUserId(c.AppContext.Session().UserId), + app.UploadFileSetUserId(creatorId), app.UploadFileSetTimestamp(timestamp), app.UploadFileSetContentLength(r.ContentLength), app.UploadFileSetClientId(clientId)) @@ -267,6 +273,11 @@ NextPart: continue NextPart } + isBookmark := false + if val, queryErr := strconv.ParseBool(r.URL.Query().Get(model.BookmarkFileOwner)); queryErr == nil { + isBookmark = val + } + // A file part. if c.Params.ChannelId == "" && asStream == nil { @@ -279,7 +290,7 @@ NextPart: return nil } - return uploadFileMultipartLegacy(c, mr, timestamp) + return uploadFileMultipartLegacy(c, mr, timestamp, isBookmark) } c.RequireChannelId() @@ -312,9 +323,15 @@ NextPart: audit.AddEventParameter(auditRec, "channel_id", c.Params.ChannelId) audit.AddEventParameter(auditRec, "client_id", clientId) + creatorId := c.AppContext.Session().UserId + if isBookmark { + creatorId = model.BookmarkFileOwner + audit.AddEventParameter(auditRec, model.BookmarkFileOwner, true) + } + info, appErr := c.App.UploadFileX(c.AppContext, c.Params.ChannelId, filename, part, app.UploadFileSetTeamId(FileTeamId), - app.UploadFileSetUserId(c.AppContext.Session().UserId), + app.UploadFileSetUserId(creatorId), app.UploadFileSetTimestamp(timestamp), app.UploadFileSetContentLength(-1), app.UploadFileSetClientId(clientId)) @@ -353,7 +370,7 @@ NextPart: // borrowing from http.ParseMultipartForm. If successful it returns a // *model.FileUploadResponse filled in with the individual model.FileInfo's. func uploadFileMultipartLegacy(c *Context, mr *multipart.Reader, - timestamp time.Time) *model.FileUploadResponse { + timestamp time.Time, isBookmark bool) *model.FileUploadResponse { // Parse the entire form. form, err := mr.ReadForm(*c.App.Config().FileSettings.MaxFileSize) if err != nil { @@ -414,9 +431,15 @@ func uploadFileMultipartLegacy(c *Context, mr *multipart.Reader, audit.AddEventParameter(auditRec, "channel_id", channelId) audit.AddEventParameter(auditRec, "client_id", clientId) + creatorId := c.AppContext.Session().UserId + if isBookmark { + creatorId = model.BookmarkFileOwner + audit.AddEventParameter(auditRec, model.BookmarkFileOwner, true) + } + info, appErr := c.App.UploadFileX(c.AppContext, c.Params.ChannelId, fileHeader.Filename, f, app.UploadFileSetTeamId(FileTeamId), - app.UploadFileSetUserId(c.AppContext.Session().UserId), + app.UploadFileSetUserId(creatorId), app.UploadFileSetTimestamp(timestamp), app.UploadFileSetContentLength(-1), app.UploadFileSetClientId(clientId)) @@ -461,8 +484,12 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { audit.AddEventParameterAuditable(auditRec, "file", info) perm := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), info.ChannelId, model.PermissionReadChannelContent) - - if info.CreatorId != c.AppContext.Session().UserId && !perm { + if info.CreatorId == model.BookmarkFileOwner { + if !perm { + c.SetPermissionError(model.PermissionReadChannelContent) + return + } + } else if info.CreatorId != c.AppContext.Session().UserId && !perm { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -495,7 +522,12 @@ func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) { } perm := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), info.ChannelId, model.PermissionReadChannelContent) - if info.CreatorId != c.AppContext.Session().UserId && !perm { + if info.CreatorId == model.BookmarkFileOwner { + if !perm { + c.SetPermissionError(model.PermissionReadChannelContent) + return + } + } else if info.CreatorId != c.AppContext.Session().UserId && !perm { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -539,12 +571,17 @@ func getFileLink(c *Context, w http.ResponseWriter, r *http.Request) { audit.AddEventParameterAuditable(auditRec, "file", info) perm := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), info.ChannelId, model.PermissionReadChannelContent) - if info.CreatorId != c.AppContext.Session().UserId && !perm { + if info.CreatorId == model.BookmarkFileOwner { + if !perm { + c.SetPermissionError(model.PermissionReadChannelContent) + return + } + } else if info.CreatorId != c.AppContext.Session().UserId && !perm { c.SetPermissionError(model.PermissionReadChannelContent) return } - if info.PostId == "" { + if info.PostId == "" && info.CreatorId != model.BookmarkFileOwner { c.Err = model.NewAppError("getPublicLink", "api.file.get_public_link.no_post.app_error", nil, "file_id="+info.Id, http.StatusBadRequest) return } @@ -573,7 +610,12 @@ func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) { } perm := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), info.ChannelId, model.PermissionReadChannelContent) - if info.CreatorId != c.AppContext.Session().UserId && !perm { + if info.CreatorId == model.BookmarkFileOwner { + if !perm { + c.SetPermissionError(model.PermissionReadChannelContent) + return + } + } else if info.CreatorId != c.AppContext.Session().UserId && !perm { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -608,8 +650,12 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) { } perm := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), info.ChannelId, model.PermissionReadChannelContent) - - if info.CreatorId != c.AppContext.Session().UserId && !perm { + if info.CreatorId == model.BookmarkFileOwner { + if !perm { + c.SetPermissionError(model.PermissionReadChannelContent) + return + } + } else if info.CreatorId != c.AppContext.Session().UserId && !perm { c.SetPermissionError(model.PermissionReadChannelContent) return } diff --git a/server/channels/api4/file_test.go b/server/channels/api4/file_test.go index 10bd0c91bf..27ba615d15 100644 --- a/server/channels/api4/file_test.go +++ b/server/channels/api4/file_test.go @@ -96,6 +96,7 @@ func testUploadFilesPost( blobs [][]byte, clientIds []string, useChunked bool, + isBookmark bool, ) (*model.FileUploadResponse, *model.Response, error) { // Do not check len(clientIds), leave it entirely to the user to // provide. The server will error out if it does not match the number @@ -120,6 +121,10 @@ func testUploadFilesPost( postURL += fmt.Sprintf("&client_id=%v", url.QueryEscape(clientIds[i])) } + if isBookmark { + postURL += "&bookmark=true" + } + fur, resp, err := testDoUploadFileRequest(t, c, postURL, blob, ct, cl) if err != nil { return nil, resp, err @@ -145,6 +150,7 @@ func testUploadFilesMultipart( names []string, blobs [][]byte, clientIds []string, + isBookmark bool, ) ( *model.FileUploadResponse, *model.Response, @@ -185,7 +191,11 @@ func testUploadFilesMultipart( } require.NoError(t, mw.Close()) - fur, resp, err := testDoUploadFileRequest(t, c, "", mwBody.Bytes(), mw.FormDataContentType(), -1) + url := "" + if isBookmark { + url += "?bookmark=true" + } + fur, resp, err := testDoUploadFileRequest(t, c, url, mwBody.Bytes(), mw.FormDataContentType(), -1) if err != nil { return nil, resp, err } @@ -230,6 +240,7 @@ func TestUploadFiles(t *testing.T) { expectedImageMiniPreview []bool setupConfig func(a *app.App) func(a *app.App) checkResponse func(t testing.TB, resp *model.Response) + uploadAsBookmark bool }{ // Upload a bunch of files, mixed images and non-images { @@ -578,6 +589,20 @@ func TestUploadFiles(t *testing.T) { } }, }, + { + title: "Bookmark images", + names: []string{"orientation_test_5.jpeg"}, + expectedImageThumbnailNames: []string{"orientation_test_5_expected_thumb.jpeg"}, + expectedImagePreviewNames: []string{"orientation_test_5_expected_preview.jpeg"}, + channelId: channel.Id, + expectImage: true, + expectedCreatorId: model.BookmarkFileOwner, + expectedImageWidths: []int{2860}, + expectedImageHeights: []int{1578}, + expectedImageHasPreview: []bool{true}, + expectedImageMiniPreview: []bool{true}, + uploadAsBookmark: true, + }, } for _, useMultipart := range []bool{true, false} { @@ -627,9 +652,9 @@ func TestUploadFiles(t *testing.T) { var resp *model.Response var err error if useMultipart { - fileResp, resp, err = testUploadFilesMultipart(t, client, channelId, tc.names, blobs, tc.clientIds) + fileResp, resp, err = testUploadFilesMultipart(t, client, channelId, tc.names, blobs, tc.clientIds, tc.uploadAsBookmark) } else { - fileResp, resp, err = testUploadFilesPost(t, client, channelId, tc.names, blobs, tc.clientIds, tc.useChunkedInSimplePost) + fileResp, resp, err = testUploadFilesPost(t, client, channelId, tc.names, blobs, tc.clientIds, tc.useChunkedInSimplePost, tc.uploadAsBookmark) } if tc.checkResponse != nil { @@ -672,6 +697,9 @@ func TestUploadFiles(t *testing.T) { ext := filepath.Ext(fname) name := fname[:len(fname)-len(ext)] expectedDir := fmt.Sprintf("%v/teams/%v/channels/%v/users/%s/%s", date, FileTeamId, channel.Id, ri.CreatorId, ri.Id) + if tc.uploadAsBookmark { + expectedDir = fmt.Sprintf("%v/teams/%v/channels/%v/%s", model.BookmarkFileOwner, FileTeamId, channel.Id, ri.Id) + } expectedPath := fmt.Sprintf("%s/%s", expectedDir, fname) assert.Equal(t, dbInfo.Path, expectedPath, fmt.Sprintf("File %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.Path, expectedPath)) @@ -774,7 +802,7 @@ func TestGetFile(t *testing.T) { CheckUnauthorizedStatus(t, resp) _, _, err = th.SystemAdminClient.GetFile(context.Background(), fileId) - require.Error(t, err) + require.NoError(t, err) CheckUnauthorizedStatus(t, resp) } @@ -889,7 +917,7 @@ func TestGetFileThumbnail(t *testing.T) { client.Logout(context.Background()) _, _, err = th.SystemAdminClient.GetFileThumbnail(context.Background(), fileId) - require.Error(t, err) + require.NoError(t, err) CheckForbiddenStatus(t, resp) } @@ -1002,7 +1030,7 @@ func TestGetFilePreview(t *testing.T) { client.Logout(context.Background()) _, _, err = th.SystemAdminClient.GetFilePreview(context.Background(), fileId) - require.Error(t, err) + require.NoError(t, err) CheckForbiddenStatus(t, resp) } @@ -1027,7 +1055,6 @@ func TestGetFileInfo(t *testing.T) { info, _, err := client.GetFileInfo(context.Background(), fileId) require.NoError(t, err) - require.NoError(t, err) require.Equal(t, fileId, info.Id, "got incorrect file") require.Equal(t, user.Id, info.CreatorId, "file should be assigned to user") require.Equal(t, "", info.PostId, "file shouldn't have a post") @@ -1057,7 +1084,7 @@ func TestGetFileInfo(t *testing.T) { client.Logout(context.Background()) _, _, err = th.SystemAdminClient.GetFileInfo(context.Background(), fileId) - require.Error(t, err) + require.NoError(t, err) CheckForbiddenStatus(t, resp) } diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 0ce3067835..34b70a46ff 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -506,6 +506,7 @@ type AppIface interface { CopyFileInfos(rctx request.CTX, userID string, fileIDs []string) ([]string, *model.AppError) CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError) CreateChannel(c request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError) + CreateChannelBookmark(c request.CTX, newBookmark *model.ChannelBookmark, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError) CreateCommand(cmd *model.Command) (*model.Command, *model.AppError) CreateCommandWebhook(commandID string, args *model.CommandArgs) (*model.CommandWebhook, *model.AppError) @@ -550,6 +551,7 @@ type AppIface interface { DeleteAllKeysForPlugin(pluginID string) *model.AppError DeleteBrandImage(rctx request.CTX) *model.AppError DeleteChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError + DeleteChannelBookmark(bookmarkId, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) DeleteCommand(commandID string) *model.AppError DeleteDraft(rctx request.CTX, draft *model.Draft, connectionID string) *model.AppError DeleteEmoji(c request.CTX, emoji *model.Emoji) *model.AppError @@ -627,9 +629,11 @@ type AppIface interface { GetAuditsPage(rctx request.CTX, userID string, page int, perPage int) (model.Audits, *model.AppError) GetAuthorizationCode(c request.CTX, w http.ResponseWriter, r *http.Request, service string, props map[string]string, loginHint string) (string, *model.AppError) GetAuthorizedAppsForUser(userID string, page, perPage int) ([]*model.OAuthApp, *model.AppError) + GetBookmark(bookmarkId string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, *model.AppError) GetBrandImage(rctx request.CTX) ([]byte, *model.AppError) GetBulkReactionsForPosts(postIDs []string) (map[string][]*model.Reaction, *model.AppError) GetChannel(c request.CTX, channelID string) (*model.Channel, *model.AppError) + GetChannelBookmarks(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, *model.AppError) GetChannelByName(c request.CTX, channelName, teamID string, includeDeleted bool) (*model.Channel, *model.AppError) GetChannelByNameForTeamName(c request.CTX, channelName, teamName string, includeDeleted bool) (*model.Channel, *model.AppError) GetChannelCounts(c request.CTX, teamID string, userID string) (*model.ChannelCounts, *model.AppError) @@ -1147,6 +1151,8 @@ type AppIface interface { UnregisterPluginForSharedChannels(pluginID string) error UnshareChannel(channelID string) (bool, error) UpdateActive(c request.CTX, user *model.User, active bool) (*model.User, *model.AppError) + UpdateChannelBookmark(c request.CTX, updateBookmark *model.ChannelBookmarkWithFileInfo, connectionId string) (*model.UpdateChannelBookmarkResponse, *model.AppError) + UpdateChannelBookmarkSortOrder(bookmarkId, channelId string, newIndex int64, connectionId string) ([]*model.ChannelBookmarkWithFileInfo, *model.AppError) UpdateChannelMemberNotifyProps(c request.CTX, data map[string]string, channelID string, userID string) (*model.ChannelMember, *model.AppError) UpdateChannelMemberRoles(c request.CTX, channelID string, userID string, newRoles string) (*model.ChannelMember, *model.AppError) UpdateChannelMemberSchemeRoles(c request.CTX, channelID string, userID string, isSchemeGuest bool, isSchemeUser bool, isSchemeAdmin bool) (*model.ChannelMember, *model.AppError) diff --git a/server/channels/app/app_test.go b/server/channels/app/app_test.go index 0a3237a365..adfa82f577 100644 --- a/server/channels/app/app_test.go +++ b/server/channels/app/app_test.go @@ -127,10 +127,26 @@ func TestDoAdvancedPermissionsMigration(t *testing.T) { model.PermissionManagePrivateChannelMembers.Id, model.PermissionDeletePost.Id, model.PermissionEditPost.Id, + model.PermissionAddBookmarkPublicChannel.Id, + model.PermissionEditBookmarkPublicChannel.Id, + model.PermissionDeleteBookmarkPublicChannel.Id, + model.PermissionOrderBookmarkPublicChannel.Id, + model.PermissionAddBookmarkPrivateChannel.Id, + model.PermissionEditBookmarkPrivateChannel.Id, + model.PermissionDeleteBookmarkPrivateChannel.Id, + model.PermissionOrderBookmarkPrivateChannel.Id, }, "channel_admin": { model.PermissionManageChannelRoles.Id, model.PermissionUseGroupMentions.Id, + model.PermissionAddBookmarkPublicChannel.Id, + model.PermissionEditBookmarkPublicChannel.Id, + model.PermissionDeleteBookmarkPublicChannel.Id, + model.PermissionOrderBookmarkPublicChannel.Id, + model.PermissionAddBookmarkPrivateChannel.Id, + model.PermissionEditBookmarkPrivateChannel.Id, + model.PermissionDeleteBookmarkPrivateChannel.Id, + model.PermissionOrderBookmarkPrivateChannel.Id, }, "team_user": { model.PermissionListTeamChannels.Id, @@ -166,6 +182,14 @@ func TestDoAdvancedPermissionsMigration(t *testing.T) { model.PermissionConvertPrivateChannelToPublic.Id, model.PermissionDeletePost.Id, model.PermissionDeleteOthersPosts.Id, + model.PermissionAddBookmarkPublicChannel.Id, + model.PermissionEditBookmarkPublicChannel.Id, + model.PermissionDeleteBookmarkPublicChannel.Id, + model.PermissionOrderBookmarkPublicChannel.Id, + model.PermissionAddBookmarkPrivateChannel.Id, + model.PermissionEditBookmarkPrivateChannel.Id, + model.PermissionDeleteBookmarkPrivateChannel.Id, + model.PermissionOrderBookmarkPrivateChannel.Id, }, "system_user": { model.PermissionListPublicTeams.Id, diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 724b80ca8b..cba64fa4fc 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -1122,7 +1122,7 @@ func buildChannelModerations(c request.CTX, channelType model.ChannelType, membe Enabled: higherScopedMemberPermissions[permissionKey], } - if permissionKey == "manage_members" { + if permissionKey == "manage_members" || permissionKey == "manage_bookmarks" { roles.Guests = nil } else { roles.Guests = &model.ChannelModeratedRole{ diff --git a/server/channels/app/channel_bookmark.go b/server/channels/app/channel_bookmark.go new file mode 100644 index 0000000000..0254a1ac3d --- /dev/null +++ b/server/channels/app/channel_bookmark.go @@ -0,0 +1,152 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +func (a *App) GetChannelBookmarks(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, *model.AppError) { + bookmarks, err := a.Srv().Store().ChannelBookmark().GetBookmarksForChannelSince(channelId, since) + if err != nil { + return nil, model.NewAppError("GetChannelBookmarks", "app.channel.bookmark.get.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + + return bookmarks, nil +} + +func (a *App) GetBookmark(bookmarkId string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, *model.AppError) { + bookmark, err := a.Srv().Store().ChannelBookmark().Get(bookmarkId, includeDeleted) + if err != nil { + return nil, model.NewAppError("GetBookmark", "app.channel.bookmark.get.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + + return bookmark, nil +} + +func (a *App) CreateChannelBookmark(c request.CTX, newBookmark *model.ChannelBookmark, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) { + newBookmark.OwnerId = c.Session().UserId //ensure that the bookmark is being created by the user who owns the session + newBookmark.Id = "" // ensure that creating a new bookmark generates a new ID + bookmark, err := a.Srv().Store().ChannelBookmark().Save(newBookmark, true) + if err != nil { + return nil, model.NewAppError("CreateChannelBookmark", "app.channel.bookmark.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + message := model.NewWebSocketEvent(model.WebsocketEventChannelBookmarkCreated, "", bookmark.ChannelId, "", nil, connectionId) + bookmarkJSON, jsonErr := json.Marshal(bookmark) + if jsonErr != nil { + return nil, model.NewAppError("CreateChannelBookmark", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + } + message.Add("bookmark", string(bookmarkJSON)) + a.Publish(message) + return bookmark, nil +} + +func (a *App) UpdateChannelBookmark(c request.CTX, updateBookmark *model.ChannelBookmarkWithFileInfo, connectionId string) (*model.UpdateChannelBookmarkResponse, *model.AppError) { + response := &model.UpdateChannelBookmarkResponse{} + if updateBookmark.OwnerId == c.Session().UserId { + isAnotherFile := updateBookmark.FileInfo != nil && updateBookmark.FileId != "" && updateBookmark.FileId != updateBookmark.FileInfo.Id + + if isAnotherFile { + if fileAlreadyAttachedErr := a.Srv().Store().ChannelBookmark().ErrorIfBookmarkFileInfoAlreadyAttached(updateBookmark.FileId); fileAlreadyAttachedErr != nil { + return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.update.app_error", nil, "", http.StatusInternalServerError).Wrap(fileAlreadyAttachedErr) + } + } + + if err := a.Srv().Store().ChannelBookmark().Update(updateBookmark.ChannelBookmark); err != nil { + return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if isAnotherFile { + fileInfo, fileErr := a.Srv().Store().FileInfo().Get(updateBookmark.FileId) + if fileErr != nil { + return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.get_existing.app_err", nil, "", http.StatusNotFound).Wrap(fileErr) + } + response.Updated = updateBookmark.ToBookmarkWithFileInfo(fileInfo) + } else { + response.Updated = updateBookmark.ToBookmarkWithFileInfo(updateBookmark.FileInfo) + } + } else { + existingBookmark, ebErr := a.Srv().Store().ChannelBookmark().Get(updateBookmark.Id, false) + if ebErr != nil { + return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.get_existing.app_err", nil, "", http.StatusNotFound).Wrap(ebErr) + } + + existingBookmark.DeleteAt = model.GetMillis() + if err := a.Srv().Store().ChannelBookmark().Delete(updateBookmark.Id, false); err != nil { + return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + newBookmark := updateBookmark.SetOriginal(c.Session().UserId) + bookmark, err := a.Srv().Store().ChannelBookmark().Save(newBookmark, false) + if err != nil { + return nil, model.NewAppError("UpdateChannelBookmark", "app.channel.bookmark.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + response.Updated = bookmark + response.Deleted = existingBookmark.ToBookmarkWithFileInfo(nil) + } + + message := model.NewWebSocketEvent(model.WebsocketEventChannelBookmarkUpdated, "", updateBookmark.ChannelId, "", nil, connectionId) + bookmarkJSON, jsonErr := json.Marshal(response) + if jsonErr != nil { + return nil, model.NewAppError("UpdateChannelBookmark", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + } + message.Add("bookmarks", string(bookmarkJSON)) + a.Publish(message) + + return response, nil +} + +func (a *App) DeleteChannelBookmark(bookmarkId, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) { + if err := a.Srv().Store().ChannelBookmark().Delete(bookmarkId, true); err != nil { + return nil, model.NewAppError("DeleteChannelBookmark", "app.channel.bookmark.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + bookmark, err := a.GetBookmark(bookmarkId, true) + if err != nil { + return nil, model.NewAppError("DeleteChannelBookmark", "app.channel.bookmark.get.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + + message := model.NewWebSocketEvent(model.WebsocketEventChannelBookmarkDeleted, "", bookmark.ChannelId, "", nil, connectionId) + bookmarkJSON, jsonErr := json.Marshal(bookmark) + if jsonErr != nil { + return nil, model.NewAppError("DeleteChannelBookmark", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + } + message.Add("bookmark", string(bookmarkJSON)) + a.Publish(message) + + return bookmark, nil +} + +func (a *App) UpdateChannelBookmarkSortOrder(bookmarkId, channelId string, newIndex int64, connectionId string) ([]*model.ChannelBookmarkWithFileInfo, *model.AppError) { + bookmarks, err := a.Srv().Store().ChannelBookmark().UpdateSortOrder(bookmarkId, channelId, newIndex) + if err != nil { + var iiErr *store.ErrInvalidInput + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &iiErr): + return nil, model.NewAppError("UpdateSortOrder", "app.channel.bookmark.update_sort.invalid_input.app_error", nil, "", http.StatusBadRequest).Wrap(err) + case errors.As(err, &nfErr): + return nil, model.NewAppError("UpdateSortOrder", "app.channel.bookmark.update_sort.missing_bookmark.app_error", nil, "", http.StatusNotFound).Wrap(err) + default: + return nil, model.NewAppError("UpdateSortOrder", "app.channel.bookmark.update_sort.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + message := model.NewWebSocketEvent(model.WebsocketEventChannelBookmarkSorted, "", channelId, "", nil, connectionId) + bookmarkJSON, jsonErr := json.Marshal(bookmarks) + if jsonErr != nil { + return nil, model.NewAppError("UpdateSortOrder", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) + } + message.Add("bookmarks", string(bookmarkJSON)) + a.Publish(message) + + return bookmarks, nil +} diff --git a/server/channels/app/channel_bookmark_test.go b/server/channels/app/channel_bookmark_test.go new file mode 100644 index 0000000000..3c3070d28e --- /dev/null +++ b/server/channels/app/channel_bookmark_test.go @@ -0,0 +1,498 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "fmt" + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func find_bookmark(slice []*model.ChannelBookmarkWithFileInfo, id string) *model.ChannelBookmarkWithFileInfo { + for _, element := range slice { + if element.Id == id { + return element + } + } + return nil +} + +func createBookmark(name string, bookmarkType model.ChannelBookmarkType, channelId string, fileId string) *model.ChannelBookmark { + bookmark := &model.ChannelBookmark{ + ChannelId: channelId, + DisplayName: name, + LinkUrl: "https://mattermost.com", + Type: bookmarkType, + Emoji: ":smile:", + FileId: fileId, + } + + return bookmark +} + +func TestCreateBookmark(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("create a channel bookmark", func(t *testing.T) { + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + + bookmark1 := createBookmark("Link bookmark test", model.ChannelBookmarkLink, th.BasicChannel.Id, "") + bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark1, "") + require.Nil(t, err) + require.NotNil(t, bookmarkResp) + + assert.Equal(t, bookmarkResp.ChannelId, th.BasicChannel.Id) + assert.NotEmpty(t, bookmarkResp.Id) + + bookmark2 := createBookmark("File bookmark test", model.ChannelBookmarkFile, th.BasicChannel.Id, "") + + bookmarkResp, err = th.App.CreateChannelBookmark(th.Context, bookmark2, "") + assert.Nil(t, bookmarkResp) + assert.NotNil(t, err) + }) + + t.Run("Cannot create more than MaxBookmarksPerChannel", func(t *testing.T) { + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + + for i := 1; i < model.MaxBookmarksPerChannel; i++ { + bookmark := createBookmark(fmt.Sprintf("Link bookmark test %d", i), model.ChannelBookmarkLink, th.BasicChannel.Id, "") + bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark, "") + require.Nil(t, err) + require.NotNil(t, bookmarkResp) + assert.Equal(t, bookmarkResp.ChannelId, th.BasicChannel.Id) + assert.NotEmpty(t, bookmarkResp.Id) + } + + bookmark := createBookmark("Bookmark that should not be added", model.ChannelBookmarkLink, th.BasicChannel.Id, "") + bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark, "") + assert.Nil(t, bookmarkResp) + assert.NotNil(t, err) + }) +} + +func TestUpdateBookmark(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + var updateBookmark *model.ChannelBookmarkWithFileInfo + + var testUpdateAnotherFile = func(th *TestHelper, t *testing.T) { + file := &model.FileInfo{ + Id: model.NewId(), + CreatorId: model.BookmarkFileOwner, + Path: "somepath", + ThumbnailPath: "thumbpath", + PreviewPath: "prevPath", + Name: "test file", + Extension: "png", + MimeType: "images/png", + Size: 873182, + Width: 3076, + Height: 2200, + HasPreviewImage: true, + } + + th.App.Srv().Store().FileInfo().Save(th.Context, file) + defer th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file.Id) + + bookmark2 := createBookmark("File to be updated", model.ChannelBookmarkFile, th.BasicChannel.Id, file.Id) + bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark2, "") + require.Nil(t, err) + require.NotNil(t, bookmarkResp) + + file2 := &model.FileInfo{ + Id: model.NewId(), + CreatorId: model.BookmarkFileOwner, + Path: "somepath", + ThumbnailPath: "thumbpath", + PreviewPath: "prevPath", + Name: "test file", + Extension: "png", + MimeType: "images/png", + Size: 873182, + Width: 3076, + Height: 2200, + HasPreviewImage: true, + } + + th.App.Srv().Store().FileInfo().Save(th.Context, file2) + th.App.Srv().Store().FileInfo().AttachToPost(th.Context, file2.Id, model.NewId(), th.BasicChannel.Id, model.BookmarkFileOwner) + defer th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file2.Id) + + bookmark2.FileId = file2.Id + bookmarkResp, err = th.App.CreateChannelBookmark(th.Context, bookmark2, "") + require.NotNil(t, err) + require.Nil(t, bookmarkResp) + } + + t.Run("same user update a channel bookmark", func(t *testing.T) { + bookmark1 := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark1, "") + require.Nil(t, err) + require.NotNil(t, bookmarkResp) + + updateBookmark = bookmarkResp.Clone() + updateBookmark.DisplayName = "New name" + time.Sleep(1 * time.Millisecond) // to avoid collisions + response, _ := th.App.UpdateChannelBookmark(th.Context, updateBookmark, "") + require.NotNil(t, response) + assert.Greater(t, response.Updated.UpdateAt, response.Updated.CreateAt) + + testUpdateAnotherFile(th, t) + }) + + t.Run("another user update a channel bookmark", func(t *testing.T) { + updateBookmark2 := updateBookmark.Clone() + updateBookmark2.DisplayName = "Another new name" + th.Context.Session().UserId = th.BasicUser2.Id + response, _ := th.App.UpdateChannelBookmark(th.Context, updateBookmark2, "") + require.NotNil(t, response) + assert.Equal(t, response.Updated.OriginalId, response.Deleted.Id) + assert.Equal(t, response.Updated.DeleteAt, int64(0)) + assert.Greater(t, response.Deleted.DeleteAt, int64(0)) + assert.Equal(t, "Another new name", response.Updated.DisplayName) + assert.Equal(t, "New name", response.Deleted.DisplayName) + + testUpdateAnotherFile(th, t) + }) + + t.Run("update an already deleted channel bookmark", func(t *testing.T) { + bookmark1 := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark1, "") + require.Nil(t, err) + require.NotNil(t, bookmarkResp) + + updateBookmark = bookmarkResp.Clone() + _, err = th.App.DeleteChannelBookmark(updateBookmark.Id, "") + assert.Nil(t, err) + + updateBookmark.DisplayName = "New name" + _, err = th.App.UpdateChannelBookmark(th.Context, updateBookmark, "") + assert.NotNil(t, err) + }) + + t.Run("update a nonexisting channel bookmark", func(t *testing.T) { + updateBookmark := &model.ChannelBookmark{ + Id: model.NewId(), + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + _, err := th.App.UpdateChannelBookmark(th.Context, updateBookmark.ToBookmarkWithFileInfo(nil), "") + assert.NotNil(t, err) + assert.Equal(t, "app.channel.bookmark.get_existing.app_err", err.Id) + }) +} + +func TestDeleteBookmark(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("delete a channel bookmark", func(t *testing.T) { + bookmark1 := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + bookmarkResp, err := th.App.CreateChannelBookmark(th.Context, bookmark1, "") + require.Nil(t, err) + require.NotNil(t, bookmarkResp) + + bookmarkResp, err = th.App.DeleteChannelBookmark(bookmarkResp.Id, "") + require.Nil(t, err) + require.NotNil(t, bookmarkResp) + assert.Greater(t, bookmarkResp.DeleteAt, int64(0)) + }) +} + +func TestGetChannelBookmarks(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + + bookmark1 := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Bookmark 1", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + th.App.CreateChannelBookmark(th.Context, bookmark1, "") + + file := &model.FileInfo{ + Id: model.NewId(), + CreatorId: model.BookmarkFileOwner, + Path: "somepath", + ThumbnailPath: "thumbpath", + PreviewPath: "prevPath", + Name: "test file", + Extension: "png", + MimeType: "images/png", + Size: 873182, + Width: 3076, + Height: 2200, + HasPreviewImage: true, + } + + th.App.Srv().Store().FileInfo().Save(th.Context, file) + defer th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file.Id) + + bookmark2 := &model.ChannelBookmark{ + ChannelId: th.BasicChannel.Id, + DisplayName: "Bookmark 2", + FileId: file.Id, + Type: model.ChannelBookmarkFile, + Emoji: ":smile:", + } + + th.App.CreateChannelBookmark(th.Context, bookmark2, "") + + t.Run("get bookmarks of a channel", func(t *testing.T) { + bookmarks, err := th.App.GetChannelBookmarks(th.BasicChannel.Id, 0) + require.Nil(t, err) + require.NotNil(t, bookmarks) + assert.Len(t, bookmarks, 2) + }) + + t.Run("get bookmarks of a channel after one is deleted (aka only return the changed bookmarks)", func(t *testing.T) { + now := model.GetMillis() + th.App.DeleteChannelBookmark(bookmark1.Id, "") + + bookmarks, err := th.App.GetChannelBookmarks(th.BasicChannel.Id, 0) + require.Nil(t, err) + require.NotNil(t, bookmarks) + assert.Len(t, bookmarks, 1) + + bookmarks, err = th.App.GetChannelBookmarks(th.BasicChannel.Id, now) + require.Nil(t, err) + require.NotNil(t, bookmarks) + assert.Len(t, bookmarks, 1) + + deleted := false + for _, b := range bookmarks { + if b.DeleteAt > 0 { + deleted = true + break + } + } + assert.Equal(t, deleted, true) + }) +} + +func TestUpdateChannelBookmarkSortOrder(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + channelId := th.BasicChannel.Id + th.Context.Session().UserId = th.BasicUser.Id // set the user for the session + + bookmark0 := &model.ChannelBookmark{ + ChannelId: channelId, + DisplayName: "Bookmark 0", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + file := &model.FileInfo{ + Id: model.NewId(), + CreatorId: model.BookmarkFileOwner, + Path: "somepath", + ThumbnailPath: "thumbpath", + PreviewPath: "prevPath", + Name: "test file", + Extension: "png", + MimeType: "images/png", + Size: 873182, + Width: 3076, + Height: 2200, + HasPreviewImage: true, + } + + bookmark1 := &model.ChannelBookmark{ + ChannelId: channelId, + DisplayName: "Bookmark 1", + FileId: file.Id, + Type: model.ChannelBookmarkFile, + Emoji: ":smile:", + } + + _, err := th.App.Srv().Store().FileInfo().Save(th.Context, file) + require.NoError(t, err) + defer th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, file.Id) + + bookmark2 := &model.ChannelBookmark{ + ChannelId: channelId, + DisplayName: "Bookmark 2", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + } + + bookmark3 := &model.ChannelBookmark{ + ChannelId: channelId, + DisplayName: "Bookmark 3", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + } + + bookmark4 := &model.ChannelBookmark{ + ChannelId: channelId, + DisplayName: "Bookmark 4", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + } + + bookmarkResp, appErr := th.App.CreateChannelBookmark(th.Context, bookmark0, "") + require.Nil(t, appErr) + require.NotNil(t, bookmarkResp) + bookmark0 = bookmarkResp.ChannelBookmark.Clone() + + bookmarkResp, appErr = th.App.CreateChannelBookmark(th.Context, bookmark1, "") + require.Nil(t, appErr) + require.NotNil(t, bookmarkResp) + bookmark1 = bookmarkResp.ChannelBookmark.Clone() + + bookmarkResp, appErr = th.App.CreateChannelBookmark(th.Context, bookmark2, "") + require.Nil(t, appErr) + require.NotNil(t, bookmarkResp) + bookmark2 = bookmarkResp.ChannelBookmark.Clone() + + bookmarkResp, appErr = th.App.CreateChannelBookmark(th.Context, bookmark3, "") + require.Nil(t, appErr) + require.NotNil(t, bookmarkResp) + bookmark3 = bookmarkResp.ChannelBookmark.Clone() + + bookmarkResp, appErr = th.App.CreateChannelBookmark(th.Context, bookmark4, "") + require.Nil(t, appErr) + require.NotNil(t, bookmarkResp) + bookmark4 = bookmarkResp.ChannelBookmark.Clone() + + t.Run("change order of bookmarks first to last", func(t *testing.T) { + bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark0.Id, channelId, int64(4), "") + require.Nil(t, sortErr) + require.NotNil(t, bookmarks) + + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks last to first", func(t *testing.T) { + bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark0.Id, channelId, int64(0), "") + require.Nil(t, sortErr) + require.NotNil(t, bookmarks) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks first to third", func(t *testing.T) { + bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark0.Id, channelId, int64(2), "") + require.Nil(t, sortErr) + require.NotNil(t, bookmarks) + + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + + // now reset order + th.App.UpdateChannelBookmarkSortOrder(bookmark0.Id, channelId, int64(0), "") + }) + + t.Run("change order of bookmarks second to third", func(t *testing.T) { + bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark1.Id, channelId, int64(2), "") + require.Nil(t, sortErr) + require.NotNil(t, bookmarks) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks third to second", func(t *testing.T) { + bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark1.Id, channelId, int64(1), "") + require.Nil(t, sortErr) + require.NotNil(t, bookmarks) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks last to previous last", func(t *testing.T) { + bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark4.Id, channelId, int64(3), "") + require.Nil(t, sortErr) + require.NotNil(t, bookmarks) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks last to second", func(t *testing.T) { + bookmarks, sortErr := th.App.UpdateChannelBookmarkSortOrder(bookmark3.Id, channelId, int64(1), "") + require.Nil(t, sortErr) + require.NotNil(t, bookmarks) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks error when new index is out of bounds", func(t *testing.T) { + _, appErr = th.App.UpdateChannelBookmarkSortOrder(bookmark3.Id, channelId, int64(-1), "") + assert.Error(t, appErr) + _, appErr = th.App.UpdateChannelBookmarkSortOrder(bookmark3.Id, channelId, int64(5), "") + assert.Error(t, appErr) + }) + + t.Run("change order of bookmarks error when bookmark not found", func(t *testing.T) { + _, appErr = th.App.UpdateChannelBookmarkSortOrder(model.NewId(), channelId, int64(0), "") + assert.Error(t, appErr) + }) +} diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go index 5968f18704..44cc457ee8 100644 --- a/server/channels/app/channel_test.go +++ b/server/channels/app/channel_test.go @@ -1716,6 +1716,7 @@ func TestPatchChannelModerationsForChannel(t *testing.T) { createReactions := model.ChannelModeratedPermissions[1] manageMembers := model.ChannelModeratedPermissions[2] channelMentions := model.ChannelModeratedPermissions[3] + manageBookmarks := model.ChannelModeratedPermissions[4] nonChannelModeratedPermission := model.PermissionCreateBot.Id @@ -1809,6 +1810,26 @@ func TestPatchChannelModerationsForChannel(t *testing.T) { }, }, }, + { + Name: "Removing manage bookmarks from members role", + ChannelModerationsPatch: []*model.ChannelModerationPatch{ + { + Name: &manageBookmarks, + Roles: &model.ChannelModeratedRolesPatch{Members: model.NewBool(false)}, + }, + }, + PermissionsModeratedByPatch: map[string]*model.ChannelModeratedRoles{ + manageBookmarks: { + Members: &model.ChannelModeratedRole{Value: false, Enabled: true}, + }, + }, + RevertChannelModerationsPatch: []*model.ChannelModerationPatch{ + { + Name: &manageBookmarks, + Roles: &model.ChannelModeratedRolesPatch{Members: model.NewBool(true)}, + }, + }, + }, { Name: "Removing create posts from guests role", ChannelModerationsPatch: []*model.ChannelModerationPatch{ @@ -1881,6 +1902,18 @@ func TestPatchChannelModerationsForChannel(t *testing.T) { ShouldError: false, ShouldHaveNoChannelScheme: true, }, + { + Name: "Removing manage bookmarks from guests role should not error", + ChannelModerationsPatch: []*model.ChannelModerationPatch{ + { + Name: &manageBookmarks, + Roles: &model.ChannelModeratedRolesPatch{Guests: model.NewBool(false)}, + }, + }, + PermissionsModeratedByPatch: map[string]*model.ChannelModeratedRoles{}, + ShouldError: false, + ShouldHaveNoChannelScheme: true, + }, { Name: "Removing a permission that is not channel moderated should not error", ChannelModerationsPatch: []*model.ChannelModerationPatch{ @@ -1981,6 +2014,12 @@ func TestPatchChannelModerationsForChannel(t *testing.T) { Members: model.NewBool(true), }, }, + { + Name: &manageBookmarks, + Roles: &model.ChannelModeratedRolesPatch{ + Members: model.NewBool(true), + }, + }, }, PermissionsModeratedByPatch: map[string]*model.ChannelModeratedRoles{}, ShouldHaveNoChannelScheme: true, @@ -2039,7 +2078,7 @@ func TestPatchChannelModerationsForChannel(t *testing.T) { if permission, found := tc.PermissionsModeratedByPatch[moderation.Name]; found && permission.Guests != nil { require.Equal(t, moderation.Roles.Guests.Value, permission.Guests.Value) require.Equal(t, moderation.Roles.Guests.Enabled, permission.Guests.Enabled) - } else if moderation.Name == manageMembers { + } else if moderation.Name == manageMembers || moderation.Name == "manage_bookmarks" { require.Empty(t, moderation.Roles.Guests) } else { require.Equal(t, moderation.Roles.Guests.Value, true) diff --git a/server/channels/app/file.go b/server/channels/app/file.go index 19de7b4ae0..c24b0e6ece 100644 --- a/server/channels/app/file.go +++ b/server/channels/app/file.go @@ -704,6 +704,7 @@ func (t *UploadFileTask) init(a *App) { t.fileinfo.CreatorId = t.UserId t.fileinfo.CreateAt = t.Timestamp.UnixNano() / int64(time.Millisecond) t.fileinfo.Path = t.pathPrefix() + t.Name + t.fileinfo.ChannelId = t.ChannelId t.limitedInput = &io.LimitedReader{ R: t.Input, @@ -950,6 +951,12 @@ func (t *UploadFileTask) postprocessImage(file io.Reader) { } func (t UploadFileTask) pathPrefix() string { + if t.UserId == model.BookmarkFileOwner { + return model.BookmarkFileOwner + + "/teams/" + t.TeamId + + "/channels/" + t.ChannelId + + "/" + t.fileinfo.Id + "/" + } return t.Timestamp.Format("20060102") + "/teams/" + t.TeamId + "/channels/" + t.ChannelId + @@ -1003,6 +1010,9 @@ func (a *App) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTe info.CreateAt = now.UnixNano() / int64(time.Millisecond) pathPrefix := now.Format("20060102") + "/teams/" + teamID + "/channels/" + channelID + "/users/" + userID + "/" + info.Id + "/" + if userID == model.BookmarkFileOwner { + pathPrefix = model.BookmarkFileOwner + "/teams/" + teamID + "/channels/" + channelID + "/" + info.Id + "/" + } info.Path = pathPrefix + filename if info.IsImage() && !info.IsSvg() { diff --git a/server/channels/app/file_test.go b/server/channels/app/file_test.go index 52b91d83dd..b89ef8c802 100644 --- a/server/channels/app/file_test.go +++ b/server/channels/app/file_test.go @@ -96,6 +96,16 @@ func TestDoUploadFile(t *testing.T) { value = fmt.Sprintf("20090305/teams/%v/channels/%v/users/%v/%v/%v", teamID, channelID, userID, info4.Id, filename) assert.Equal(t, value, info4.Path, "stored file at incorrect path") + + info5, err := th.App.DoUploadFile(th.Context, time.Date(2008, 3, 5, 1, 2, 3, 4, time.Local), teamID, channelID, model.BookmarkFileOwner, filename, data) + require.Nil(t, err, "DoUploadFile should succeed with valid data") + defer func() { + th.App.Srv().Store().FileInfo().PermanentDelete(th.Context, info5.Id) + th.App.RemoveFile(info3.Path) + }() + + value = fmt.Sprintf("%v/teams/%v/channels/%v/%v/%v", model.BookmarkFileOwner, teamID, channelID, info5.Id, filename) + assert.Equal(t, value, info5.Path, "stored file at incorrect path") } func TestUploadFile(t *testing.T) { diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 3046efba75..829763b1d5 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -1994,6 +1994,28 @@ func (a *OpenTracingAppLayer) CreateChannel(c request.CTX, channel *model.Channe return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) CreateChannelBookmark(c request.CTX, newBookmark *model.ChannelBookmark, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateChannelBookmark") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.CreateChannelBookmark(c, newBookmark, connectionId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) CreateChannelScheme(c request.CTX, channel *model.Channel) (*model.Scheme, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateChannelScheme") @@ -3093,6 +3115,28 @@ func (a *OpenTracingAppLayer) DeleteChannel(c request.CTX, channel *model.Channe return resultVar0 } +func (a *OpenTracingAppLayer) DeleteChannelBookmark(bookmarkId string, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteChannelBookmark") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.DeleteChannelBookmark(bookmarkId, connectionId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) DeleteChannelScheme(c request.CTX, channel *model.Channel) (*model.Channel, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteChannelScheme") @@ -5138,6 +5182,28 @@ func (a *OpenTracingAppLayer) GetAuthorizedAppsForUser(userID string, page int, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetBookmark(bookmarkId string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetBookmark") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetBookmark(bookmarkId, includeDeleted) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetBot(rctx request.CTX, botUserId string, includeDeleted bool) (*model.Bot, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetBot") @@ -5248,6 +5314,28 @@ func (a *OpenTracingAppLayer) GetChannel(c request.CTX, channelID string) (*mode return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetChannelBookmarks(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetChannelBookmarks") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetChannelBookmarks(channelId, since) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetChannelByName(c request.CTX, channelName string, teamID string, includeDeleted bool) (*model.Channel, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetChannelByName") @@ -17562,6 +17650,50 @@ func (a *OpenTracingAppLayer) UpdateChannel(c request.CTX, channel *model.Channe return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) UpdateChannelBookmark(c request.CTX, updateBookmark *model.ChannelBookmarkWithFileInfo, connectionId string) (*model.UpdateChannelBookmarkResponse, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateChannelBookmark") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.UpdateChannelBookmark(c, updateBookmark, connectionId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) UpdateChannelBookmarkSortOrder(bookmarkId string, channelId string, newIndex int64, connectionId string) ([]*model.ChannelBookmarkWithFileInfo, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateChannelBookmarkSortOrder") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.UpdateChannelBookmarkSortOrder(bookmarkId, channelId, newIndex, connectionId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) UpdateChannelMemberNotifyProps(c request.CTX, data map[string]string, channelID string, userID string) (*model.ChannelMember, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateChannelMemberNotifyProps") diff --git a/server/channels/app/permissions_migrations.go b/server/channels/app/permissions_migrations.go index 5e731d5551..c68dea55ee 100644 --- a/server/channels/app/permissions_migrations.go +++ b/server/channels/app/permissions_migrations.go @@ -1156,6 +1156,31 @@ func (a *App) getAddOutgoingOAuthConnectionsPermissions() (permissionsMap, error return t, nil } +func (a *App) getAddChannelBookmarksPermissionsMigration() (permissionsMap, error) { + transformations := []permissionTransformation{} + + transformations = append(transformations, permissionTransformation{ + On: permissionOr( + isRole(model.ChannelUserRoleId), + isRole(model.ChannelAdminRoleId), + isRole(model.TeamAdminRoleId), + isRole(model.SystemAdminRoleId), + ), + Add: []string{ + model.PermissionAddBookmarkPublicChannel.Id, + model.PermissionEditBookmarkPublicChannel.Id, + model.PermissionDeleteBookmarkPublicChannel.Id, + model.PermissionOrderBookmarkPublicChannel.Id, + model.PermissionAddBookmarkPrivateChannel.Id, + model.PermissionEditBookmarkPrivateChannel.Id, + model.PermissionDeleteBookmarkPrivateChannel.Id, + model.PermissionOrderBookmarkPrivateChannel.Id, + }, + }) + + return transformations, nil +} + // DoPermissionsMigrations execute all the permissions migrations need by the current version. func (a *App) DoPermissionsMigrations() error { return a.Srv().doPermissionsMigrations() @@ -1202,6 +1227,7 @@ func (s *Server) doPermissionsMigrations() error { {Key: model.MigrationKeyAddReadChannelContentPermissions, Migration: a.getAddChannelReadContentPermissions}, {Key: model.MigrationKeyAddIPFilteringPermissions, Migration: a.getAddIPFilterPermissionsMigration}, {Key: model.MigrationKeyAddOutgoingOAuthConnectionsPermissions, Migration: a.getAddOutgoingOAuthConnectionsPermissions}, + {Key: model.MigrationKeyAddChannelBookmarksPermissions, Migration: a.getAddChannelBookmarksPermissionsMigration}, } roles, err := s.Store().Role().GetAll() diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index 1f27d892ca..9e3075b56b 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -236,6 +236,8 @@ channels/db/migrations/mysql/000118_create_index_poststats.down.sql channels/db/migrations/mysql/000118_create_index_poststats.up.sql channels/db/migrations/mysql/000119_msteams_shared_channels_opts.down.sql channels/db/migrations/mysql/000119_msteams_shared_channels_opts.up.sql +channels/db/migrations/mysql/000120_create_channelbookmarks_table.down.sql +channels/db/migrations/mysql/000120_create_channelbookmarks_table.up.sql channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000002_create_team_members.down.sql @@ -472,3 +474,5 @@ channels/db/migrations/postgres/000118_create_index_poststats.down.sql channels/db/migrations/postgres/000118_create_index_poststats.up.sql channels/db/migrations/postgres/000119_msteams_shared_channels_opts.down.sql channels/db/migrations/postgres/000119_msteams_shared_channels_opts.up.sql +channels/db/migrations/postgres/000120_create_channelbookmarks_table.down.sql +channels/db/migrations/postgres/000120_create_channelbookmarks_table.up.sql diff --git a/server/channels/db/migrations/mysql/000120_create_channelbookmarks_table.down.sql b/server/channels/db/migrations/mysql/000120_create_channelbookmarks_table.down.sql new file mode 100644 index 0000000000..8e686e4bd0 --- /dev/null +++ b/server/channels/db/migrations/mysql/000120_create_channelbookmarks_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ChannelBookmarks; \ No newline at end of file diff --git a/server/channels/db/migrations/mysql/000120_create_channelbookmarks_table.up.sql b/server/channels/db/migrations/mysql/000120_create_channelbookmarks_table.up.sql new file mode 100644 index 0000000000..56b821522d --- /dev/null +++ b/server/channels/db/migrations/mysql/000120_create_channelbookmarks_table.up.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS ChannelBookmarks ( + Id varchar(26) NOT NULL, + OwnerId varchar(26) NOT NULL, + ChannelId varchar(26) NOT NULL, + FileInfoId varchar(26) DEFAULT NULL, + CreateAt bigint(20) DEFAULT 0, + UpdateAt bigint(20) DEFAULT 0, + DeleteAt bigint(20) DEFAULT 0, + DisplayName text, + SortOrder bigint(20) DEFAULT 0, + LinkUrl text DEFAULT NULL, + ImageUrl text DEFAULT NULL, + Emoji varchar(64) DEFAULT NULL, + Type ENUM('link', 'file'), + OriginalId varchar(26) DEFAULT NULL, + ParentId varchar(26) DEFAULT NULL, + PRIMARY KEY (Id), + KEY idx_channelbookmarks_channelid (ChannelId), + KEY idx_channelbookmarks_update_at (UpdateAt), + KEY idx_channelbookmarks_delete_at (DeleteAt) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/channels/db/migrations/postgres/000120_create_channelbookmarks_table.down.sql b/server/channels/db/migrations/postgres/000120_create_channelbookmarks_table.down.sql new file mode 100644 index 0000000000..34f393f6b1 --- /dev/null +++ b/server/channels/db/migrations/postgres/000120_create_channelbookmarks_table.down.sql @@ -0,0 +1,18 @@ +DROP INDEX IF EXISTS idx_channelbookmarks_channelid; +DROP INDEX IF EXISTS idx_channelbookmarks_update_at; +DROP INDEX IF EXISTS idx_channelbookmarks_delete_at; + +DROP TABLE IF EXISTS channelbookmarks; + +DO +$$ +BEGIN + IF EXISTS (SELECT * FROM pg_type typ + INNER JOIN pg_namespace nsp ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = current_schema() + AND typ.typname = 'channel_bookmark_type') THEN + DROP TYPE channel_bookmark_type; + END IF; +END; +$$ +LANGUAGE plpgsql; \ No newline at end of file diff --git a/server/channels/db/migrations/postgres/000120_create_channelbookmarks_table.up.sql b/server/channels/db/migrations/postgres/000120_create_channelbookmarks_table.up.sql new file mode 100644 index 0000000000..91758a566d --- /dev/null +++ b/server/channels/db/migrations/postgres/000120_create_channelbookmarks_table.up.sql @@ -0,0 +1,34 @@ +DO +$$ +BEGIN + IF NOT EXISTS (SELECT * FROM pg_type typ + INNER JOIN pg_namespace nsp ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = current_schema() + AND typ.typname = 'channel_bookmark_type') THEN + CREATE TYPE channel_bookmark_type AS ENUM ('link', 'file'); + END IF; +END; +$$ +LANGUAGE plpgsql; + +CREATE TABLE IF NOT EXISTS channelbookmarks ( + id varchar(26) PRIMARY KEY, + ownerid varchar(26) NOT NULL, + channelid varchar(26) NOT NULL, + fileinfoid varchar(26) DEFAULT NULL, + createat bigint DEFAULT 0, + updateat bigint DEFAULT 0, + deleteat bigint DEFAULT 0, + displayname text DEFAULT '', + sortorder integer DEFAULT 0, + linkurl text DEFAULT NULL, + imageurl text DEFAULT NULL, + emoji varchar(64) DEFAULT NULL, + type channel_bookmark_type DEFAULT 'link', + originalid varchar(26) DEFAULT NULL, + parentid varchar(26) DEFAULT NULL +); + +CREATE INDEX IF NOT EXISTS idx_channelbookmarks_channelid ON channelbookmarks (channelid); +CREATE INDEX IF NOT EXISTS idx_channelbookmarks_update_at ON channelbookmarks (updateat); +CREATE INDEX IF NOT EXISTS idx_channelbookmarks_delete_at ON channelbookmarks (deleteat); diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index dbaa293536..38d2a08c6d 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -22,6 +22,7 @@ type OpenTracingLayer struct { AuditStore store.AuditStore BotStore store.BotStore ChannelStore store.ChannelStore + ChannelBookmarkStore store.ChannelBookmarkStore ChannelMemberHistoryStore store.ChannelMemberHistoryStore ClusterDiscoveryStore store.ClusterDiscoveryStore CommandStore store.CommandStore @@ -78,6 +79,10 @@ func (s *OpenTracingLayer) Channel() store.ChannelStore { return s.ChannelStore } +func (s *OpenTracingLayer) ChannelBookmark() store.ChannelBookmarkStore { + return s.ChannelBookmarkStore +} + func (s *OpenTracingLayer) ChannelMemberHistory() store.ChannelMemberHistoryStore { return s.ChannelMemberHistoryStore } @@ -261,6 +266,11 @@ type OpenTracingLayerChannelStore struct { Root *OpenTracingLayer } +type OpenTracingLayerChannelBookmarkStore struct { + store.ChannelBookmarkStore + Root *OpenTracingLayer +} + type OpenTracingLayerChannelMemberHistoryStore struct { store.ChannelMemberHistoryStore Root *OpenTracingLayer @@ -2689,6 +2699,132 @@ func (s *OpenTracingLayerChannelStore) UserBelongsToChannels(userID string, chan return result, err } +func (s *OpenTracingLayerChannelBookmarkStore) Delete(bookmarkId string, deleteFile bool) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelBookmarkStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ChannelBookmarkStore.Delete(bookmarkId, deleteFile) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileId string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelBookmarkStore.ErrorIfBookmarkFileInfoAlreadyAttached") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ChannelBookmarkStore.ErrorIfBookmarkFileInfoAlreadyAttached(fileId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerChannelBookmarkStore) Get(Id string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelBookmarkStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelBookmarkStore.Get(Id, includeDeleted) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerChannelBookmarkStore) GetBookmarksForChannelSince(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelBookmarkStore.GetBookmarksForChannelSince") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelBookmarkStore.GetBookmarksForChannelSince(channelId, since) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerChannelBookmarkStore) Save(bookmark *model.ChannelBookmark, increaseSortOrder bool) (*model.ChannelBookmarkWithFileInfo, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelBookmarkStore.Save") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelBookmarkStore.Save(bookmark, increaseSortOrder) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerChannelBookmarkStore) Update(bookmark *model.ChannelBookmark) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelBookmarkStore.Update") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ChannelBookmarkStore.Update(bookmark) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerChannelBookmarkStore) UpdateSortOrder(bookmarkId string, channelId string, newIndex int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelBookmarkStore.UpdateSortOrder") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelBookmarkStore.UpdateSortOrder(bookmarkId, channelId, newIndex) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerChannelMemberHistoryStore) DeleteOrphanedRows(limit int) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelMemberHistoryStore.DeleteOrphanedRows") @@ -13284,6 +13420,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.AuditStore = &OpenTracingLayerAuditStore{AuditStore: childStore.Audit(), Root: &newStore} newStore.BotStore = &OpenTracingLayerBotStore{BotStore: childStore.Bot(), Root: &newStore} newStore.ChannelStore = &OpenTracingLayerChannelStore{ChannelStore: childStore.Channel(), Root: &newStore} + newStore.ChannelBookmarkStore = &OpenTracingLayerChannelBookmarkStore{ChannelBookmarkStore: childStore.ChannelBookmark(), Root: &newStore} newStore.ChannelMemberHistoryStore = &OpenTracingLayerChannelMemberHistoryStore{ChannelMemberHistoryStore: childStore.ChannelMemberHistory(), Root: &newStore} newStore.ClusterDiscoveryStore = &OpenTracingLayerClusterDiscoveryStore{ClusterDiscoveryStore: childStore.ClusterDiscovery(), Root: &newStore} newStore.CommandStore = &OpenTracingLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index d3bec3b3ce..bc4b1f9ae6 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -26,6 +26,7 @@ type RetryLayer struct { AuditStore store.AuditStore BotStore store.BotStore ChannelStore store.ChannelStore + ChannelBookmarkStore store.ChannelBookmarkStore ChannelMemberHistoryStore store.ChannelMemberHistoryStore ClusterDiscoveryStore store.ClusterDiscoveryStore CommandStore store.CommandStore @@ -82,6 +83,10 @@ func (s *RetryLayer) Channel() store.ChannelStore { return s.ChannelStore } +func (s *RetryLayer) ChannelBookmark() store.ChannelBookmarkStore { + return s.ChannelBookmarkStore +} + func (s *RetryLayer) ChannelMemberHistory() store.ChannelMemberHistoryStore { return s.ChannelMemberHistoryStore } @@ -265,6 +270,11 @@ type RetryLayerChannelStore struct { Root *RetryLayer } +type RetryLayerChannelBookmarkStore struct { + store.ChannelBookmarkStore + Root *RetryLayer +} + type RetryLayerChannelMemberHistoryStore struct { store.ChannelMemberHistoryStore Root *RetryLayer @@ -2987,6 +2997,153 @@ func (s *RetryLayerChannelStore) UserBelongsToChannels(userID string, channelIds } +func (s *RetryLayerChannelBookmarkStore) Delete(bookmarkId string, deleteFile bool) error { + + tries := 0 + for { + err := s.ChannelBookmarkStore.Delete(bookmarkId, deleteFile) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileId string) error { + + tries := 0 + for { + err := s.ChannelBookmarkStore.ErrorIfBookmarkFileInfoAlreadyAttached(fileId) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelBookmarkStore) Get(Id string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, error) { + + tries := 0 + for { + result, err := s.ChannelBookmarkStore.Get(Id, includeDeleted) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelBookmarkStore) GetBookmarksForChannelSince(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + + tries := 0 + for { + result, err := s.ChannelBookmarkStore.GetBookmarksForChannelSince(channelId, since) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelBookmarkStore) Save(bookmark *model.ChannelBookmark, increaseSortOrder bool) (*model.ChannelBookmarkWithFileInfo, error) { + + tries := 0 + for { + result, err := s.ChannelBookmarkStore.Save(bookmark, increaseSortOrder) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelBookmarkStore) Update(bookmark *model.ChannelBookmark) error { + + tries := 0 + for { + err := s.ChannelBookmarkStore.Update(bookmark) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + +func (s *RetryLayerChannelBookmarkStore) UpdateSortOrder(bookmarkId string, channelId string, newIndex int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + + tries := 0 + for { + result, err := s.ChannelBookmarkStore.UpdateSortOrder(bookmarkId, channelId, newIndex) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelMemberHistoryStore) DeleteOrphanedRows(limit int) (int64, error) { tries := 0 @@ -15157,6 +15314,7 @@ func New(childStore store.Store) *RetryLayer { newStore.AuditStore = &RetryLayerAuditStore{AuditStore: childStore.Audit(), Root: &newStore} newStore.BotStore = &RetryLayerBotStore{BotStore: childStore.Bot(), Root: &newStore} newStore.ChannelStore = &RetryLayerChannelStore{ChannelStore: childStore.Channel(), Root: &newStore} + newStore.ChannelBookmarkStore = &RetryLayerChannelBookmarkStore{ChannelBookmarkStore: childStore.ChannelBookmark(), Root: &newStore} newStore.ChannelMemberHistoryStore = &RetryLayerChannelMemberHistoryStore{ChannelMemberHistoryStore: childStore.ChannelMemberHistory(), Root: &newStore} newStore.ClusterDiscoveryStore = &RetryLayerClusterDiscoveryStore{ClusterDiscoveryStore: childStore.ClusterDiscovery(), Root: &newStore} newStore.CommandStore = &RetryLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index 5a1e4654a6..5729799cb4 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -20,6 +20,7 @@ func genStore() *mocks.Store { mock.On("Bot").Return(&mocks.BotStore{}) mock.On("Channel").Return(&mocks.ChannelStore{}) mock.On("ChannelMemberHistory").Return(&mocks.ChannelMemberHistoryStore{}) + mock.On("ChannelBookmark").Return(&mocks.ChannelBookmarkStore{}) mock.On("ClusterDiscovery").Return(&mocks.ClusterDiscoveryStore{}) mock.On("RemoteCluster").Return(&mocks.RemoteClusterStore{}) mock.On("Command").Return(&mocks.CommandStore{}) @@ -61,6 +62,7 @@ func genStore() *mocks.Store { mock.On("PostPersistentNotification").Return(&mocks.PostPersistentNotificationStore{}) mock.On("TrueUpReview").Return(&mocks.TrueUpReviewStore{}) mock.On("DesktopTokens").Return(&mocks.DesktopTokensStore{}) + mock.On("ChannelBookmark").Return(&mocks.ChannelBookmarkStore{}) return mock } diff --git a/server/channels/store/sqlstore/channel_bookmark_store.go b/server/channels/store/sqlstore/channel_bookmark_store.go new file mode 100644 index 0000000000..ede8885155 --- /dev/null +++ b/server/channels/store/sqlstore/channel_bookmark_store.go @@ -0,0 +1,380 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "strconv" + + sq "github.com/mattermost/squirrel" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/mattermost/mattermost/server/v8/channels/utils" + + "github.com/pkg/errors" +) + +type SqlChannelBookmarkStore struct { + *SqlStore +} + +func newSqlChannelBookmarkStore(sqlStore *SqlStore) store.ChannelBookmarkStore { + return &SqlChannelBookmarkStore{sqlStore} +} + +func bookmarkWithFileInfoSliceColumns() []string { + return []string{ + "cb.Id", + "cb.OwnerId", + "cb.ChannelId", + "cb.FileInfoId", + "cb.CreateAt", + "cb.UpdateAt", + "cb.DeleteAt", + "cb.DisplayName", + "cb.SortOrder", + "cb.LinkUrl", + "cb.ImageUrl", + "cb.Emoji", + "cb.Type", + "COALESCE(cb.OriginalId, '') as OriginalId", + "COALESCE(fi.Id, '') as FileId", + "COALESCE(fi.Name, '') as FileName", + "COALESCE(fi.Extension, '') as Extension", + "COALESCE(fi.Size, 0) as Size", + "COALESCE(fi.MimeType, '') as MimeType", + "COALESCE(fi.Width, 0) as Width", + "COALESCE(fi.Height, 0) as Height", + "COALESCE(fi.HasPreviewImage, false) as HasPreviewImage", + "COALESCE(fi.MiniPreview, '') as MiniPreview", + } +} + +func (s *SqlChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileId string) error { + existingQuery := s.getSubQueryBuilder(). + Select("FileInfoId"). + From("ChannelBookmarks"). + Where(sq.And{ + sq.Eq{"FileInfoId": fileId}, + sq.Eq{"DeleteAt": 0}, + }) + + alreadyAttachedQuery := s.getQueryBuilder(). + Select("COUNT(*)"). + From("FileInfo"). + Where(sq.Or{ + sq.Expr("Id IN (?)", existingQuery), + sq.And{ + sq.Or{ + sq.NotEq{"PostId": ""}, + sq.NotEq{"CreatorId": model.BookmarkFileOwner}, + }, + sq.Eq{"Id": fileId}, + }, + }) + + var attached int64 + err := s.GetReplicaX().GetBuilder(&attached, alreadyAttachedQuery) + if err != nil { + return errors.Wrap(err, "unable_to_save_channel_bookmark") + } + + if attached > 0 { + return store.NewErrInvalidInput("ChannelBookmarks", "FileInfoId", fileId) + } + + return nil +} + +func (s *SqlChannelBookmarkStore) Get(Id string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, error) { + query := s.getQueryBuilder(). + Select(bookmarkWithFileInfoSliceColumns()...). + From("ChannelBookmarks cb"). + LeftJoin("FileInfo fi ON cb.FileInfoId = fi.Id"). + Where(sq.Eq{"cb.Id": Id}) + + if !includeDeleted { + query = query.Where(sq.Eq{"cb.DeleteAt": 0}) + } + + queryString, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "channel_bookmark_getforchanneltsince_tosql") + } + + bookmark := model.ChannelBookmarkAndFileInfo{} + + if err := s.GetReplicaX().Get(&bookmark, queryString, args...); err != nil { + return nil, store.NewErrNotFound("ChannelBookmark", Id) + } + + return bookmark.ToChannelBookmarkWithFileInfo(), nil +} + +func (s *SqlChannelBookmarkStore) Save(bookmark *model.ChannelBookmark, increaseSortOrder bool) (b *model.ChannelBookmarkWithFileInfo, err error) { + bookmark.PreSave() + if err := bookmark.IsValid(); err != nil { + return nil, err + } + + transaction, err := s.GetMasterX().Beginx() + if err != nil { + return nil, err + } + defer finalizeTransactionX(transaction, &err) + + var currentBookmarksCount int64 + query := s.getQueryBuilder(). + Select("COUNT(*) as count"). + From("ChannelBookmarks"). + Where(sq.Eq{"ChannelId": bookmark.ChannelId, "DeleteAt": 0}) + err = transaction.GetBuilder(¤tBookmarksCount, query) + if err != nil { + return nil, errors.Wrap(err, "failed while getting the count of ChannelBookmarks") + } + + if currentBookmarksCount >= model.MaxBookmarksPerChannel { + return nil, store.NewErrLimitExceeded("bookmarks_per_channel", int(currentBookmarksCount), "channelId="+bookmark.ChannelId) + } + + if bookmark.FileId != "" { + err = s.ErrorIfBookmarkFileInfoAlreadyAttached(bookmark.FileId) + if err != nil { + return nil, errors.Wrap(err, "unable_to_save_channel_bookmark") + } + } + + if increaseSortOrder { + var sortOrder int64 + query := s.getQueryBuilder(). + Select("COALESCE(MAX(SortOrder), -1) as SortOrder"). + From("ChannelBookmarks"). + Where(sq.Eq{"ChannelId": bookmark.ChannelId, "DeleteAt": 0}) + + err = transaction.GetBuilder(&sortOrder, query) + if err != nil { + return nil, errors.Wrap(err, "failed while getting the sortOrder from ChannelBookmarks") + } + bookmark.SortOrder = sortOrder + 1 + } + + sql, args, sqlErr := s.getQueryBuilder(). + Insert("ChannelBookmarks"). + Columns("Id", "CreateAt", "UpdateAt", "DeleteAt", "ChannelId", "OwnerId", "FileInfoId", "DisplayName", "SortOrder", "LinkUrl", "ImageUrl", "Emoji", "Type"). + Values(bookmark.Id, bookmark.CreateAt, bookmark.UpdateAt, bookmark.DeleteAt, bookmark.ChannelId, bookmark.OwnerId, bookmark.FileId, bookmark.DisplayName, bookmark.SortOrder, bookmark.LinkUrl, bookmark.ImageUrl, bookmark.Emoji, bookmark.Type). + ToSql() + + if sqlErr != nil { + return nil, errors.Wrap(err, "insert_channel_bookmark_to_sql") + } + + if _, insertErr := transaction.Exec(sql, args...); insertErr != nil { + return nil, errors.Wrap(insertErr, "unable_to_save_channel_bookmark") + } + + var fileInfo model.FileInfo + if bookmark.FileId != "" { + query, args, queryErr := s.getQueryBuilder(). + Select("Id, Name, Extension, Size, MimeType, Width, Height, HasPreviewImage, MiniPreview"). + From("FileInfo"). + Where(sq.Eq{"Id": bookmark.FileId}). + ToSql() + if queryErr != nil { + return nil, errors.Wrap(queryErr, "channel_bookmark_get_file_info_to_sql") + } + if queryErr = transaction.Get(&fileInfo, query, args...); queryErr != nil { + return nil, errors.Wrap(queryErr, "unable_to_get_channel_bookmark_file_info") + } + } + + err = transaction.Commit() + return bookmark.ToBookmarkWithFileInfo(&fileInfo), err +} + +func (s *SqlChannelBookmarkStore) Update(bookmark *model.ChannelBookmark) error { + bookmark.PreUpdate() + if err := bookmark.IsValid(); err != nil { + return err + } + + query, args, err := s.getQueryBuilder(). + Update("ChannelBookmarks"). + Set("DisplayName", bookmark.DisplayName). + Set("SortOrder", bookmark.SortOrder). + Set("LinkUrl", bookmark.LinkUrl). + Set("ImageUrl", bookmark.ImageUrl). + Set("Emoji", bookmark.Emoji). + Set("FileInfoId", bookmark.FileId). + Set("UpdateAt", bookmark.UpdateAt). + Where(sq.Eq{ + "Id": bookmark.Id, + "DeleteAt": 0, + }). + ToSql() + if err != nil { + return errors.Wrap(err, "channel_bookmark_update_tosql") + } + + res, err := s.GetMasterX().Exec(query, args...) + if err != nil { + return errors.Wrapf(err, "failed to update channel bookmark with id=%s", bookmark.Id) + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return errors.Wrapf(err, "failed to get affected rows after updating bookmark with id=%s", bookmark.Id) + } + if rowsAffected == 0 { + return store.NewErrNotFound("ChannelBookmark", bookmark.Id) + } + return nil +} + +func (s *SqlChannelBookmarkStore) UpdateSortOrder(bookmarkId, channelId string, newIndex int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + now := model.GetMillis() + transaction, err := s.GetMasterX().Beginx() + if err != nil { + return nil, err + } + defer finalizeTransactionX(transaction, &err) + + bookmarks, err := s.GetBookmarksForChannelSince(channelId, 0) + if err != nil { + return nil, err + } + + if (int(newIndex) > len(bookmarks)-1) || newIndex < 0 { + return nil, store.NewErrInvalidInput("ChannelBookmark", "SortOrder", newIndex) + } + + currentIndex := -1 + var current *model.ChannelBookmarkWithFileInfo + for index, b := range bookmarks { + if b.Id == bookmarkId { + currentIndex = index + current = b + break + } + } + + if currentIndex == -1 { + return nil, store.NewErrNotFound("ChannelBookmark", bookmarkId) + } + + bookmarks = utils.RemoveElementFromSliceAtIndex(bookmarks, currentIndex) + bookmarks = utils.InsertElementToSliceAtIndex(bookmarks, current, int(newIndex)) + caseStmt := sq.Case() + query := s.getQueryBuilder(). + Update("ChannelBookmarks") + + ids := []string{} + for index, b := range bookmarks { + b.SortOrder = int64(index) + caseStmt = caseStmt.When(sq.Eq{"Id": b.Id}, strconv.FormatInt(int64(index), 10)) + ids = append(ids, b.Id) + } + query = query.Set("SortOrder", caseStmt) + query = query.Set("UpdateAt", now) + query = query.Where(sq.Eq{"Id": ids}) + queryStr, args, queryErr := query.ToSql() + if queryErr != nil { + return nil, queryErr + } + + if _, updateSortOrderErr := transaction.Exec(queryStr, args...); updateSortOrderErr != nil { + return nil, updateSortOrderErr + } + + err = transaction.Commit() + return bookmarks, err +} + +func (s *SqlChannelBookmarkStore) Delete(bookmarkId string, deleteFile bool) error { + now := model.GetMillis() + transaction, err := s.GetMasterX().Beginx() + if err != nil { + return err + } + defer finalizeTransactionX(transaction, &err) + query, args, err := s.getQueryBuilder(). + Update("ChannelBookmarks"). + Set("DeleteAt", now). + Set("UpdateAt", now). + Where(sq.Eq{"Id": bookmarkId}). + ToSql() + if err != nil { + return errors.Wrap(err, "channel_bookmark_delete_tosql") + } + + _, err = transaction.Exec(query, args...) + if err != nil { + return errors.Wrapf(err, "failed to delete channel bookmark with id=%s", bookmarkId) + } + + if deleteFile { + fileIdQuery := s.getSubQueryBuilder(). + Select("FileInfoId"). + From("ChannelBookmarks"). + Where(sq.And{ + sq.Eq{"Id": bookmarkId}, + sq.Eq{"DeleteAt": 0}, + }) + + fileQuery, fileArgs, fileErr := s.getQueryBuilder(). + Update("FileInfo"). + Set("DeleteAt", now). + Set("UpdateAt", now). + Where(sq.Expr("Id IN (?)", fileIdQuery)). + ToSql() + + if fileErr != nil { + return errors.Wrap(err, "channel_bookmark_delete_tosql") + } + + _, err = transaction.Exec(fileQuery, fileArgs...) + if err != nil { + return errors.Wrapf(err, "failed to delete channel bookmark with id=%s", bookmarkId) + } + } + + return transaction.Commit() +} + +func (s *SqlChannelBookmarkStore) GetBookmarksForChannelSince(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + query := s.getQueryBuilder(). + Select(bookmarkWithFileInfoSliceColumns()...). + From("ChannelBookmarks cb"). + LeftJoin("FileInfo fi ON cb.FileInfoId = fi.Id"). + Where(sq.Eq{"cb.ChannelId": channelId}) + + if since > 0 { + query = query.Where(sq.Or{ + sq.GtOrEq{"cb.UpdateAt": since}, + sq.GtOrEq{"cb.DeleteAt": since}, + }) + } else { + query = query.Where(sq.Eq{"cb.DeleteAt": 0}) + } + + query = query. + OrderBy("cb.SortOrder ASC"). + OrderBy("cb.DeleteAt ASC"). + Limit(model.MaxBookmarksPerChannel * 2) // limit to the double of the cap as an edge case + queryString, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "channel_bookmark_getforchanneltsince_tosql") + } + + bookmarkRows := []model.ChannelBookmarkAndFileInfo{} + bookmarks := []*model.ChannelBookmarkWithFileInfo{} + + if err := s.GetReplicaX().Select(&bookmarkRows, queryString, args...); err != nil { + return nil, errors.Wrapf(err, "failed to find bookmarks") + } + + for _, bookmark := range bookmarkRows { + bookmarks = append(bookmarks, bookmark.ToChannelBookmarkWithFileInfo()) + } + + return bookmarks, nil +} diff --git a/server/channels/store/sqlstore/channel_bookmark_store_test.go b/server/channels/store/sqlstore/channel_bookmark_store_test.go new file mode 100644 index 0000000000..e73869fb42 --- /dev/null +++ b/server/channels/store/sqlstore/channel_bookmark_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost/server/v8/channels/store/storetest" +) + +func TestChannelBookmarkStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestChannelBookmarkStore) +} diff --git a/server/channels/store/sqlstore/file_info_store.go b/server/channels/store/sqlstore/file_info_store.go index 664e958fa8..a54c784e18 100644 --- a/server/channels/store/sqlstore/file_info_store.go +++ b/server/channels/store/sqlstore/file_info_store.go @@ -465,12 +465,12 @@ func (fs SqlFileInfoStore) PermanentDelete(rctx request.CTX, fileId string) erro func (fs SqlFileInfoStore) PermanentDeleteBatch(rctx request.CTX, endTime int64, limit int64) (int64, error) { var query string if fs.DriverName() == "postgres" { - query = "DELETE from FileInfo WHERE Id = any (array (SELECT Id FROM FileInfo WHERE CreateAt < ? LIMIT ?))" + query = "DELETE from FileInfo WHERE Id = any (array (SELECT Id FROM FileInfo WHERE CreateAt < ? AND CreatorId != ? LIMIT ?))" } else { - query = "DELETE from FileInfo WHERE CreateAt < ? LIMIT ?" + query = "DELETE from FileInfo WHERE CreateAt < ? AND CreatorId != ? LIMIT ?" } - sqlResult, err := fs.GetMasterX().Exec(query, endTime, limit) + sqlResult, err := fs.GetMasterX().Exec(query, endTime, model.BookmarkFileOwner, limit) if err != nil { return 0, errors.Wrap(err, "failed to delete FileInfos in batch") } diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index a48c05908b..628facf665 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -111,6 +111,7 @@ type SqlStoreStores struct { postPersistentNotification store.PostPersistentNotificationStore trueUpReview store.TrueUpReviewStore desktopTokens store.DesktopTokensStore + channelBookmarks store.ChannelBookmarkStore } type SqlStore struct { @@ -236,6 +237,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface store.stores.postPersistentNotification = newSqlPostPersistentNotificationStore(store) store.stores.trueUpReview = newSqlTrueUpReviewStore(store) store.stores.desktopTokens = newSqlDesktopTokensStore(store, metrics) + store.stores.channelBookmarks = newSqlChannelBookmarkStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -1043,6 +1045,10 @@ func (ss *SqlStore) DesktopTokens() store.DesktopTokensStore { return ss.stores.desktopTokens } +func (ss *SqlStore) ChannelBookmark() store.ChannelBookmarkStore { + return ss.stores.channelBookmarks +} + func (ss *SqlStore) DropAllTables() { if ss.DriverName() == model.DatabaseDriverPostgres { ss.masterX.Exec(`DO diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 96fddced13..4f992db631 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -91,6 +91,7 @@ type Store interface { PostPersistentNotification() PostPersistentNotificationStore TrueUpReview() TrueUpReviewStore DesktopTokens() DesktopTokensStore + ChannelBookmark() ChannelBookmarkStore } type RetentionPolicyStore interface { @@ -1033,6 +1034,16 @@ type TrueUpReviewStore interface { Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) } +type ChannelBookmarkStore interface { + ErrorIfBookmarkFileInfoAlreadyAttached(fileId string) error + Get(Id string, includeDeleted bool) (b *model.ChannelBookmarkWithFileInfo, err error) + Save(bookmark *model.ChannelBookmark, increaseSortOrder bool) (b *model.ChannelBookmarkWithFileInfo, err error) + Update(bookmark *model.ChannelBookmark) error + UpdateSortOrder(bookmarkId, channelId string, newIndex int64) ([]*model.ChannelBookmarkWithFileInfo, error) + Delete(bookmarkId string, deleteFile bool) error + GetBookmarksForChannelSince(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, error) +} + // ChannelSearchOpts contains options for searching channels. // // NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records. diff --git a/server/channels/store/storetest/channel_bookmark.go b/server/channels/store/storetest/channel_bookmark.go new file mode 100644 index 0000000000..03e35a2007 --- /dev/null +++ b/server/channels/store/storetest/channel_bookmark.go @@ -0,0 +1,483 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +func find_bookmark(slice []*model.ChannelBookmarkWithFileInfo, id string) *model.ChannelBookmarkWithFileInfo { + for _, element := range slice { + if element.Id == id { + return element + } + } + return nil +} + +func TestChannelBookmarkStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + t.Run("SaveChannelBookmark", func(t *testing.T) { testSaveChannelBookmark(t, rctx, ss) }) + t.Run("UpdateChannelBookmark", func(t *testing.T) { testUpdateChannelBookmark(t, rctx, ss) }) + t.Run("UpdateSortOrderChannelBookmark", func(t *testing.T) { testUpdateSortOrderChannelBookmark(t, rctx, ss) }) + t.Run("DeleteChannelBookmark", func(t *testing.T) { testDeleteChannelBookmark(t, rctx, ss) }) + t.Run("GetChannelBookmark", func(t *testing.T) { testGetChannelBookmark(t, rctx, ss) }) +} + +func testSaveChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) { + channelId := model.NewId() + userId := model.NewId() + + bookmark1 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + file := &model.FileInfo{ + Id: model.NewId(), + CreatorId: model.BookmarkFileOwner, + Path: "somepath", + ThumbnailPath: "thumbpath", + PreviewPath: "prevPath", + Name: "test file", + Extension: "png", + MimeType: "images/png", + Size: 873182, + Width: 3076, + Height: 2200, + HasPreviewImage: true, + } + + bookmark2 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "file bookmark test", + FileId: file.Id, + Type: model.ChannelBookmarkFile, + Emoji: ":smile:", + } + + bookmark3 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "file already attached", + FileId: file.Id, + Type: model.ChannelBookmarkFile, + Emoji: ":smile:", + } + + file2 := &model.FileInfo{ + Id: model.NewId(), + CreatorId: userId, + Path: "somepath", + ThumbnailPath: "thumbpath", + PreviewPath: "prevPath", + Name: "test file", + Extension: "png", + MimeType: "images/png", + Size: 873182, + Width: 3076, + Height: 2200, + HasPreviewImage: true, + } + + bookmark4 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "file already attached to a post", + FileId: file2.Id, + Type: model.ChannelBookmarkFile, + Emoji: ":smile:", + } + + _, err := ss.FileInfo().Save(rctx, file) + require.NoError(t, err) + defer ss.FileInfo().PermanentDelete(rctx, file.Id) + + _, err = ss.FileInfo().Save(rctx, file2) + require.NoError(t, err) + defer ss.FileInfo().PermanentDelete(rctx, file2.Id) + + err = ss.FileInfo().AttachToPost(rctx, file2.Id, model.NewId(), channelId, userId) + require.NoError(t, err) + + t.Run("save bookmarks", func(t *testing.T) { + bookmarkResp, err := ss.ChannelBookmark().Save(bookmark1.Clone(), true) + assert.NoError(t, err) + + assert.NotEmpty(t, bookmarkResp.Id) + assert.Equal(t, bookmark1.ChannelId, bookmarkResp.ChannelId) + assert.Nil(t, bookmarkResp.FileInfo) + + bookmarkResp, err = ss.ChannelBookmark().Save(bookmark2.Clone(), true) + assert.NoError(t, err) + + assert.NotEmpty(t, bookmarkResp.Id) + assert.Equal(t, bookmark2.ChannelId, bookmarkResp.ChannelId) + assert.NotNil(t, bookmarkResp.FileInfo) + + bookmarks, err := ss.ChannelBookmark().GetBookmarksForChannelSince(channelId, 0) + assert.NoError(t, err) + assert.Len(t, bookmarks, 2) + + _, err = ss.ChannelBookmark().Save(bookmark3.Clone(), true) + assert.Error(t, err) // Error as the file is attached to a bookmark + + _, err = ss.ChannelBookmark().Save(bookmark4.Clone(), true) + assert.Error(t, err) // Error as the file is attached to a post + }) +} + +func testUpdateChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) { + channelId := model.NewId() + userId := model.NewId() + + bookmark1 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + } + + t.Run("update bookmark", func(t *testing.T) { + bookmarkResp, err := ss.ChannelBookmark().Save(bookmark1.Clone(), true) + assert.NoError(t, err) + + now := model.GetMillis() + bookmark2 := bookmarkResp.ChannelBookmark.Clone() + bookmark2.DisplayName = "Updated display name" + bookmark2.Emoji = ":smile:" + bookmark2.LinkUrl = "https://mattermost.com/about" + + time.Sleep(time.Millisecond * 250) + + err = ss.ChannelBookmark().Update(bookmark2.Clone()) + assert.NoError(t, err) + + bookmarks, err := ss.ChannelBookmark().GetBookmarksForChannelSince(channelId, now) + assert.NoError(t, err) + assert.Len(t, bookmarks, 1) + + b := find_bookmark(bookmarks, bookmark2.Id) + assert.NotNil(t, b) + assert.Equal(t, b.DisplayName, bookmark2.DisplayName) + assert.Equal(t, b.Type, model.ChannelBookmarkLink) + assert.NotEmpty(t, b.Emoji) + assert.Equal(t, b.CreateAt, bookmark2.CreateAt) + assert.Greater(t, b.UpdateAt, bookmark2.UpdateAt) + + err = ss.ChannelBookmark().Update(bookmark1.Clone()) + assert.Error(t, err) + + bookmark3 := bookmark2.Clone() + bookmark3.Type = model.ChannelBookmarkFile + err = ss.ChannelBookmark().Update(bookmark3) + assert.Error(t, err) + }) +} + +func testUpdateSortOrderChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) { + channelId := model.NewId() + userId := model.NewId() + + bookmark0 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "Bookmark 0", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + file := &model.FileInfo{ + Id: model.NewId(), + CreatorId: model.BookmarkFileOwner, + Path: "somepath", + ThumbnailPath: "thumbpath", + PreviewPath: "prevPath", + Name: "test file", + Extension: "png", + MimeType: "images/png", + Size: 873182, + Width: 3076, + Height: 2200, + HasPreviewImage: true, + } + + bookmark1 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "Bookmark 1", + FileId: file.Id, + Type: model.ChannelBookmarkFile, + Emoji: ":smile:", + } + + _, err := ss.FileInfo().Save(rctx, file) + require.NoError(t, err) + defer ss.FileInfo().PermanentDelete(rctx, file.Id) + + bookmark2 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "Bookmark 2", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + } + + bookmark3 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "Bookmark 3", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + } + + bookmark4 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "Bookmark 4", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + } + + bookmarkResp, err := ss.ChannelBookmark().Save(bookmark0.Clone(), true) + assert.NoError(t, err) + bookmark0 = bookmarkResp.ChannelBookmark.Clone() + + assert.NotEmpty(t, bookmarkResp.Id) + assert.Equal(t, bookmark0.ChannelId, bookmarkResp.ChannelId) + assert.Nil(t, bookmarkResp.FileInfo) + + bookmarkResp, err = ss.ChannelBookmark().Save(bookmark1.Clone(), true) + assert.NoError(t, err) + bookmark1 = bookmarkResp.ChannelBookmark.Clone() + + bookmarkResp, err = ss.ChannelBookmark().Save(bookmark2.Clone(), true) + assert.NoError(t, err) + bookmark2 = bookmarkResp.ChannelBookmark.Clone() + + bookmarkResp, err = ss.ChannelBookmark().Save(bookmark3.Clone(), true) + assert.NoError(t, err) + bookmark3 = bookmarkResp.ChannelBookmark.Clone() + + bookmarkResp, err = ss.ChannelBookmark().Save(bookmark4.Clone(), true) + assert.NoError(t, err) + bookmark4 = bookmarkResp.ChannelBookmark.Clone() + + t.Run("change order of bookmarks first to last", func(t *testing.T) { + bookmarks, sortError := ss.ChannelBookmark().UpdateSortOrder(bookmark0.Id, channelId, 4) + assert.NoError(t, sortError) + + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks last to first", func(t *testing.T) { + bookmarks, sortError := ss.ChannelBookmark().UpdateSortOrder(bookmark0.Id, channelId, 0) + assert.NoError(t, sortError) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks first to third", func(t *testing.T) { + bookmarks, sortError := ss.ChannelBookmark().UpdateSortOrder(bookmark0.Id, channelId, 2) + assert.NoError(t, sortError) + + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + + // now reset order + ss.ChannelBookmark().UpdateSortOrder(bookmark0.Id, channelId, 0) + }) + + t.Run("change order of bookmarks second to third", func(t *testing.T) { + bookmarks, sortError := ss.ChannelBookmark().UpdateSortOrder(bookmark1.Id, channelId, 2) + assert.NoError(t, sortError) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks third to second", func(t *testing.T) { + bookmarks, sortError := ss.ChannelBookmark().UpdateSortOrder(bookmark1.Id, channelId, 1) + assert.NoError(t, sortError) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks last to previous last", func(t *testing.T) { + bookmarks, sortError := ss.ChannelBookmark().UpdateSortOrder(bookmark4.Id, channelId, 3) + assert.NoError(t, sortError) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks last to second", func(t *testing.T) { + bookmarks, sortError := ss.ChannelBookmark().UpdateSortOrder(bookmark3.Id, channelId, 1) + assert.NoError(t, sortError) + + assert.Equal(t, find_bookmark(bookmarks, bookmark0.Id).SortOrder, int64(0)) + assert.Equal(t, find_bookmark(bookmarks, bookmark3.Id).SortOrder, int64(1)) + assert.Equal(t, find_bookmark(bookmarks, bookmark1.Id).SortOrder, int64(2)) + assert.Equal(t, find_bookmark(bookmarks, bookmark2.Id).SortOrder, int64(3)) + assert.Equal(t, find_bookmark(bookmarks, bookmark4.Id).SortOrder, int64(4)) + }) + + t.Run("change order of bookmarks error when new index is out of bounds", func(t *testing.T) { + var iiErr *store.ErrInvalidInput + _, err = ss.ChannelBookmark().UpdateSortOrder(bookmark3.Id, channelId, -1) + assert.Error(t, err) + assert.ErrorAs(t, err, &iiErr) + _, err = ss.ChannelBookmark().UpdateSortOrder(bookmark3.Id, channelId, 5) + assert.Error(t, err) + assert.ErrorAs(t, err, &iiErr) + }) + + t.Run("change order of bookmarks error when bookmark not found", func(t *testing.T) { + _, err = ss.ChannelBookmark().UpdateSortOrder(model.NewId(), channelId, 0) + assert.Error(t, err) + var nfErr *store.ErrNotFound + assert.ErrorAs(t, err, &nfErr) + }) +} + +func testDeleteChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) { + channelId := model.NewId() + userId := model.NewId() + + bookmark1 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + file := &model.FileInfo{ + Id: model.NewId(), + CreatorId: model.BookmarkFileOwner, + Path: "somepath", + ThumbnailPath: "thumbpath", + PreviewPath: "prevPath", + Name: "test file", + Extension: "png", + MimeType: "images/png", + Size: 873182, + Width: 3076, + Height: 2200, + HasPreviewImage: true, + } + + bookmark2 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "file bookmark test", + FileId: file.Id, + Type: model.ChannelBookmarkFile, + Emoji: ":smile:", + } + + _, err := ss.FileInfo().Save(rctx, file) + require.NoError(t, err) + defer ss.FileInfo().PermanentDelete(rctx, file.Id) + + t.Run("delete bookmark", func(t *testing.T) { + now := model.GetMillis() + bookmarkResp, err := ss.ChannelBookmark().Save(bookmark1.Clone(), true) + assert.NoError(t, err) + bookmark1 = bookmarkResp.ChannelBookmark.Clone() + + assert.NotEmpty(t, bookmarkResp.Id) + assert.Equal(t, bookmark1.ChannelId, bookmarkResp.ChannelId) + assert.Nil(t, bookmarkResp.FileInfo) + + bookmarkResp, err = ss.ChannelBookmark().Save(bookmark2.Clone(), true) + assert.NoError(t, err) + bookmark2 = bookmarkResp.ChannelBookmark.Clone() + + err = ss.ChannelBookmark().Delete(bookmark2.Id, true) + assert.NoError(t, err) + + bookmarks, err := ss.ChannelBookmark().GetBookmarksForChannelSince(channelId, now) + assert.NoError(t, err) + assert.Len(t, bookmarks, 2) // we have two as the deleted record also gets returned for sync'ing purposes + + b := find_bookmark(bookmarks, bookmark2.Id) + assert.NotNil(t, b) + assert.Equal(t, bookmarks[0].Type, model.ChannelBookmarkLink) + }) +} + +func testGetChannelBookmark(t *testing.T, rctx request.CTX, ss store.Store) { + channelId := model.NewId() + userId := model.NewId() + + bookmark1 := &model.ChannelBookmark{ + ChannelId: channelId, + OwnerId: userId, + DisplayName: "Link bookmark test", + LinkUrl: "https://mattermost.com", + Type: model.ChannelBookmarkLink, + Emoji: ":smile:", + } + + t.Run("get bookmark", func(t *testing.T) { + bookmarkResp, err := ss.ChannelBookmark().Save(bookmark1.Clone(), true) + assert.NoError(t, err) + bookmark1 = bookmarkResp.ChannelBookmark.Clone() + + bookmarkResp, err = ss.ChannelBookmark().Get(bookmark1.Id, false) + assert.NoError(t, err) + + assert.NotEmpty(t, bookmarkResp.Id) + assert.Equal(t, bookmark1.ChannelId, bookmarkResp.ChannelId) + assert.Nil(t, bookmarkResp.FileInfo) + + err = ss.ChannelBookmark().Delete(bookmark1.Id, true) + assert.NoError(t, err) + + bookmarkResp, err = ss.ChannelBookmark().Get(bookmark1.Id, false) + assert.Error(t, err) + assert.Nil(t, bookmarkResp) + + bookmarkResp, err = ss.ChannelBookmark().Get(bookmark1.Id, true) + assert.NoError(t, err) + assert.NotNil(t, bookmarkResp) + }) +} diff --git a/server/channels/store/storetest/file_info_store.go b/server/channels/store/storetest/file_info_store.go index 172b9bf42f..4bb3ac1568 100644 --- a/server/channels/store/storetest/file_info_store.go +++ b/server/channels/store/storetest/file_info_store.go @@ -615,16 +615,26 @@ func testFileInfoPermanentDeleteBatch(t *testing.T, rctx request.CTX, ss store.S }) require.NoError(t, err) + bookmarkFile, err := ss.FileInfo().Save(rctx, &model.FileInfo{ // should not be deleted + PostId: postId, + ChannelId: channelId, + CreatorId: model.BookmarkFileOwner, + Path: "file.txt", + CreateAt: 1000, + }) + defer ss.FileInfo().PermanentDelete(rctx, bookmarkFile.Id) + require.NoError(t, err) + postFiles, err := ss.FileInfo().GetForPost(postId, true, false, false) require.NoError(t, err) - assert.Len(t, postFiles, 3) + assert.Len(t, postFiles, 4) _, err = ss.FileInfo().PermanentDeleteBatch(rctx, 1500, 1000) require.NoError(t, err) postFiles, err = ss.FileInfo().GetForPost(postId, true, false, false) require.NoError(t, err) - assert.Len(t, postFiles, 1) + assert.Len(t, postFiles, 2) } func testFileInfoPermanentDeleteByUser(t *testing.T, rctx request.CTX, ss store.Store) { diff --git a/server/channels/store/storetest/mocks/ChannelBookmarkStore.go b/server/channels/store/storetest/mocks/ChannelBookmarkStore.go new file mode 100644 index 0000000000..231167ce37 --- /dev/null +++ b/server/channels/store/storetest/mocks/ChannelBookmarkStore.go @@ -0,0 +1,176 @@ +// Code generated by mockery v2.23.2. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" +) + +// ChannelBookmarkStore is an autogenerated mock type for the ChannelBookmarkStore type +type ChannelBookmarkStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: bookmarkId, deleteFile +func (_m *ChannelBookmarkStore) Delete(bookmarkId string, deleteFile bool) error { + ret := _m.Called(bookmarkId, deleteFile) + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(bookmarkId, deleteFile) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ErrorIfBookmarkFileInfoAlreadyAttached provides a mock function with given fields: fileId +func (_m *ChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileId string) error { + ret := _m.Called(fileId) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(fileId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: Id, includeDeleted +func (_m *ChannelBookmarkStore) Get(Id string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, error) { + ret := _m.Called(Id, includeDeleted) + + var r0 *model.ChannelBookmarkWithFileInfo + var r1 error + if rf, ok := ret.Get(0).(func(string, bool) (*model.ChannelBookmarkWithFileInfo, error)); ok { + return rf(Id, includeDeleted) + } + if rf, ok := ret.Get(0).(func(string, bool) *model.ChannelBookmarkWithFileInfo); ok { + r0 = rf(Id, includeDeleted) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ChannelBookmarkWithFileInfo) + } + } + + if rf, ok := ret.Get(1).(func(string, bool) error); ok { + r1 = rf(Id, includeDeleted) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetBookmarksForChannelSince provides a mock function with given fields: channelId, since +func (_m *ChannelBookmarkStore) GetBookmarksForChannelSince(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + ret := _m.Called(channelId, since) + + var r0 []*model.ChannelBookmarkWithFileInfo + var r1 error + if rf, ok := ret.Get(0).(func(string, int64) ([]*model.ChannelBookmarkWithFileInfo, error)); ok { + return rf(channelId, since) + } + if rf, ok := ret.Get(0).(func(string, int64) []*model.ChannelBookmarkWithFileInfo); ok { + r0 = rf(channelId, since) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ChannelBookmarkWithFileInfo) + } + } + + if rf, ok := ret.Get(1).(func(string, int64) error); ok { + r1 = rf(channelId, since) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: bookmark, increaseSortOrder +func (_m *ChannelBookmarkStore) Save(bookmark *model.ChannelBookmark, increaseSortOrder bool) (*model.ChannelBookmarkWithFileInfo, error) { + ret := _m.Called(bookmark, increaseSortOrder) + + var r0 *model.ChannelBookmarkWithFileInfo + var r1 error + if rf, ok := ret.Get(0).(func(*model.ChannelBookmark, bool) (*model.ChannelBookmarkWithFileInfo, error)); ok { + return rf(bookmark, increaseSortOrder) + } + if rf, ok := ret.Get(0).(func(*model.ChannelBookmark, bool) *model.ChannelBookmarkWithFileInfo); ok { + r0 = rf(bookmark, increaseSortOrder) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ChannelBookmarkWithFileInfo) + } + } + + if rf, ok := ret.Get(1).(func(*model.ChannelBookmark, bool) error); ok { + r1 = rf(bookmark, increaseSortOrder) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: bookmark +func (_m *ChannelBookmarkStore) Update(bookmark *model.ChannelBookmark) error { + ret := _m.Called(bookmark) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.ChannelBookmark) error); ok { + r0 = rf(bookmark) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateSortOrder provides a mock function with given fields: bookmarkId, channelId, newIndex +func (_m *ChannelBookmarkStore) UpdateSortOrder(bookmarkId string, channelId string, newIndex int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + ret := _m.Called(bookmarkId, channelId, newIndex) + + var r0 []*model.ChannelBookmarkWithFileInfo + var r1 error + if rf, ok := ret.Get(0).(func(string, string, int64) ([]*model.ChannelBookmarkWithFileInfo, error)); ok { + return rf(bookmarkId, channelId, newIndex) + } + if rf, ok := ret.Get(0).(func(string, string, int64) []*model.ChannelBookmarkWithFileInfo); ok { + r0 = rf(bookmarkId, channelId, newIndex) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ChannelBookmarkWithFileInfo) + } + } + + if rf, ok := ret.Get(1).(func(string, string, int64) error); ok { + r1 = rf(bookmarkId, channelId, newIndex) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewChannelBookmarkStore interface { + mock.TestingT + Cleanup(func()) +} + +// NewChannelBookmarkStore creates a new instance of ChannelBookmarkStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewChannelBookmarkStore(t mockConstructorTestingTNewChannelBookmarkStore) *ChannelBookmarkStore { + mock := &ChannelBookmarkStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index eaac669a29..e45356ae71 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -72,6 +72,22 @@ func (_m *Store) Channel() store.ChannelStore { return r0 } +// ChannelBookmark provides a mock function with given fields: +func (_m *Store) ChannelBookmark() store.ChannelBookmarkStore { + ret := _m.Called() + + var r0 store.ChannelBookmarkStore + if rf, ok := ret.Get(0).(func() store.ChannelBookmarkStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.ChannelBookmarkStore) + } + } + + return r0 +} + // ChannelMemberHistory provides a mock function with given fields: func (_m *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { ret := _m.Called() diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index 3392b89a5e..90175b5203 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -65,6 +65,7 @@ type Store struct { PostPersistentNotificationStore mocks.PostPersistentNotificationStore TrueUpReviewStore mocks.TrueUpReviewStore DesktopTokensStore mocks.DesktopTokensStore + ChannelBookmarkStore mocks.ChannelBookmarkStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -110,13 +111,14 @@ func (s *Store) Draft() store.DraftStore { return &s.D func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { return &s.ChannelMemberHistoryStore } -func (s *Store) TrueUpReview() store.TrueUpReviewStore { return &s.TrueUpReviewStore } -func (s *Store) DesktopTokens() store.DesktopTokensStore { return &s.DesktopTokensStore } -func (s *Store) NotifyAdmin() store.NotifyAdminStore { return &s.NotifyAdminStore } -func (s *Store) Group() store.GroupStore { return &s.GroupStore } -func (s *Store) LinkMetadata() store.LinkMetadataStore { return &s.LinkMetadataStore } -func (s *Store) SharedChannel() store.SharedChannelStore { return &s.SharedChannelStore } -func (s *Store) PostPriority() store.PostPriorityStore { return &s.PostPriorityStore } +func (s *Store) ChannelBookmark() store.ChannelBookmarkStore { return &s.ChannelBookmarkStore } +func (s *Store) TrueUpReview() store.TrueUpReviewStore { return &s.TrueUpReviewStore } +func (s *Store) DesktopTokens() store.DesktopTokensStore { return &s.DesktopTokensStore } +func (s *Store) NotifyAdmin() store.NotifyAdminStore { return &s.NotifyAdminStore } +func (s *Store) Group() store.GroupStore { return &s.GroupStore } +func (s *Store) LinkMetadata() store.LinkMetadataStore { return &s.LinkMetadataStore } +func (s *Store) SharedChannel() store.SharedChannelStore { return &s.SharedChannelStore } +func (s *Store) PostPriority() store.PostPriorityStore { return &s.PostPriorityStore } func (s *Store) PostAcknowledgement() store.PostAcknowledgementStore { return &s.PostAcknowledgementStore } @@ -187,5 +189,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.PostAcknowledgementStore, &s.PostPersistentNotificationStore, &s.DesktopTokensStore, + &s.ChannelBookmarkStore, ) } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index c8ad803151..fd587c50be 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -22,6 +22,7 @@ type TimerLayer struct { AuditStore store.AuditStore BotStore store.BotStore ChannelStore store.ChannelStore + ChannelBookmarkStore store.ChannelBookmarkStore ChannelMemberHistoryStore store.ChannelMemberHistoryStore ClusterDiscoveryStore store.ClusterDiscoveryStore CommandStore store.CommandStore @@ -78,6 +79,10 @@ func (s *TimerLayer) Channel() store.ChannelStore { return s.ChannelStore } +func (s *TimerLayer) ChannelBookmark() store.ChannelBookmarkStore { + return s.ChannelBookmarkStore +} + func (s *TimerLayer) ChannelMemberHistory() store.ChannelMemberHistoryStore { return s.ChannelMemberHistoryStore } @@ -261,6 +266,11 @@ type TimerLayerChannelStore struct { Root *TimerLayer } +type TimerLayerChannelBookmarkStore struct { + store.ChannelBookmarkStore + Root *TimerLayer +} + type TimerLayerChannelMemberHistoryStore struct { store.ChannelMemberHistoryStore Root *TimerLayer @@ -2478,6 +2488,118 @@ func (s *TimerLayerChannelStore) UserBelongsToChannels(userID string, channelIds return result, err } +func (s *TimerLayerChannelBookmarkStore) Delete(bookmarkId string, deleteFile bool) error { + start := time.Now() + + err := s.ChannelBookmarkStore.Delete(bookmarkId, deleteFile) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelBookmarkStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerChannelBookmarkStore) ErrorIfBookmarkFileInfoAlreadyAttached(fileId string) error { + start := time.Now() + + err := s.ChannelBookmarkStore.ErrorIfBookmarkFileInfoAlreadyAttached(fileId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelBookmarkStore.ErrorIfBookmarkFileInfoAlreadyAttached", success, elapsed) + } + return err +} + +func (s *TimerLayerChannelBookmarkStore) Get(Id string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, error) { + start := time.Now() + + result, err := s.ChannelBookmarkStore.Get(Id, includeDeleted) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelBookmarkStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerChannelBookmarkStore) GetBookmarksForChannelSince(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + start := time.Now() + + result, err := s.ChannelBookmarkStore.GetBookmarksForChannelSince(channelId, since) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelBookmarkStore.GetBookmarksForChannelSince", success, elapsed) + } + return result, err +} + +func (s *TimerLayerChannelBookmarkStore) Save(bookmark *model.ChannelBookmark, increaseSortOrder bool) (*model.ChannelBookmarkWithFileInfo, error) { + start := time.Now() + + result, err := s.ChannelBookmarkStore.Save(bookmark, increaseSortOrder) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelBookmarkStore.Save", success, elapsed) + } + return result, err +} + +func (s *TimerLayerChannelBookmarkStore) Update(bookmark *model.ChannelBookmark) error { + start := time.Now() + + err := s.ChannelBookmarkStore.Update(bookmark) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelBookmarkStore.Update", success, elapsed) + } + return err +} + +func (s *TimerLayerChannelBookmarkStore) UpdateSortOrder(bookmarkId string, channelId string, newIndex int64) ([]*model.ChannelBookmarkWithFileInfo, error) { + start := time.Now() + + result, err := s.ChannelBookmarkStore.UpdateSortOrder(bookmarkId, channelId, newIndex) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelBookmarkStore.UpdateSortOrder", success, elapsed) + } + return result, err +} + func (s *TimerLayerChannelMemberHistoryStore) DeleteOrphanedRows(limit int) (int64, error) { start := time.Now() @@ -11965,6 +12087,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.AuditStore = &TimerLayerAuditStore{AuditStore: childStore.Audit(), Root: &newStore} newStore.BotStore = &TimerLayerBotStore{BotStore: childStore.Bot(), Root: &newStore} newStore.ChannelStore = &TimerLayerChannelStore{ChannelStore: childStore.Channel(), Root: &newStore} + newStore.ChannelBookmarkStore = &TimerLayerChannelBookmarkStore{ChannelBookmarkStore: childStore.ChannelBookmark(), Root: &newStore} newStore.ChannelMemberHistoryStore = &TimerLayerChannelMemberHistoryStore{ChannelMemberHistoryStore: childStore.ChannelMemberHistory(), Root: &newStore} newStore.ClusterDiscoveryStore = &TimerLayerClusterDiscoveryStore{ClusterDiscoveryStore: childStore.ClusterDiscovery(), Root: &newStore} newStore.CommandStore = &TimerLayerCommandStore{CommandStore: childStore.Command(), Root: &newStore} diff --git a/server/channels/testlib/store.go b/server/channels/testlib/store.go index 825c9f2e8b..466d0aa302 100644 --- a/server/channels/testlib/store.go +++ b/server/channels/testlib/store.go @@ -75,6 +75,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { systemStore.On("GetByName", model.MigrationKeyDeleteOrphanDrafts).Return(&model.System{Name: model.MigrationKeyDeleteOrphanDrafts, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyAddIPFilteringPermissions).Return(&model.System{Name: model.MigrationKeyAddIPFilteringPermissions, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyAddOutgoingOAuthConnectionsPermissions).Return(&model.System{Name: model.MigrationKeyAddOutgoingOAuthConnectionsPermissions, Value: "true"}, nil) + systemStore.On("GetByName", model.MigrationKeyAddChannelBookmarksPermissions).Return(&model.System{Name: model.MigrationKeyAddChannelBookmarksPermissions, Value: "true"}, nil) systemStore.On("GetByName", "CustomGroupAdminRoleCreationMigrationComplete").Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil) systemStore.On("GetByName", "products_boards").Return(&model.System{Name: "products_boards", Value: "true"}, nil) systemStore.On("GetByName", "elasticsearch_fix_channel_index_migration").Return(&model.System{Name: "elasticsearch_fix_channel_index_migration", Value: "true"}, nil) diff --git a/server/channels/utils/utils.go b/server/channels/utils/utils.go index 09b2000756..aa60c92798 100644 --- a/server/channels/utils/utils.go +++ b/server/channels/utils/utils.go @@ -87,6 +87,19 @@ func StringSliceDiff(a, b []string) []string { return result } +func InsertElementToSliceAtIndex[T comparable](slice []T, element T, index int) []T { + if len(slice) == index { + return append(slice, element) + } + slice = append(slice[:index+1], slice[index:]...) + slice[index] = element + return slice +} + +func RemoveElementFromSliceAtIndex[T comparable](slice []T, index int) []T { + return append(slice[:index], slice[index+1:]...) +} + func GetIPAddress(r *http.Request, trustedProxyIPHeader []string) string { address := "" diff --git a/server/channels/web/params.go b/server/channels/web/params.go index 57fc2f947e..67ebf8215f 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -94,6 +94,10 @@ type Params struct { IncludeChannelMemberCount string OutgoingOAuthConnectionID string + //Bookmarks + ChannelBookmarkId string + BookmarksSince int64 + // Cloud InvoiceId string } @@ -146,6 +150,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.RemoteId = props["remote_id"] params.InvoiceId = props["invoice_id"] params.OutgoingOAuthConnectionID = props["outgoing_oauth_connection_id"] + params.ChannelBookmarkId = props["bookmark_id"] params.Scope = query.Get("scope") if val, err := strconv.Atoi(query.Get("page")); err != nil || val < 0 { @@ -240,6 +245,12 @@ func ParamsFromRequest(r *http.Request) *Params { params.FilterHasMember = query.Get("filter_has_member") + if val, err := strconv.ParseInt(query.Get("bookmarks_since"), 10, 64); err != nil || val < 0 { + params.BookmarksSince = 0 + } else { + params.BookmarksSince = val + } + return params } diff --git a/server/channels/web/params_test.go b/server/channels/web/params_test.go index 591daf421c..31df044972 100644 --- a/server/channels/web/params_test.go +++ b/server/channels/web/params_test.go @@ -459,6 +459,48 @@ func TestParamsFromRequest(t *testing.T) { LimitAfter: LimitDefault, }, }, + { + "include channel bookmarks", + mustURL("/?include_bookmarks=true"), + nil, + &Params{ + BookmarksSince: 0, + + LimitAfter: LimitDefault, + + PerPage: PerPageDefault, + LogsPerPage: LogsPerPageDefault, + LimitBefore: LimitDefault, + }, + }, + { + "include channel bookmarks with negative bookmark since", + mustURL("/?include_bookmarks=true&bookmarks_since=-1"), + nil, + &Params{ + BookmarksSince: 0, + + LimitAfter: LimitDefault, + + PerPage: PerPageDefault, + LogsPerPage: LogsPerPageDefault, + LimitBefore: LimitDefault, + }, + }, + { + "include channel bookmarks with bookmark since", + mustURL("/?include_bookmarks=true&bookmarks_since=123456789"), + nil, + &Params{ + BookmarksSince: 123456789, + + LimitAfter: LimitDefault, + + PerPage: PerPageDefault, + LogsPerPage: LogsPerPageDefault, + LimitBefore: LimitDefault, + }, + }, } for _, testCase := range testCases { diff --git a/server/cmd/mmctl/commands/permissions_test.go b/server/cmd/mmctl/commands/permissions_test.go index 4d109bf524..579606eba9 100644 --- a/server/cmd/mmctl/commands/permissions_test.go +++ b/server/cmd/mmctl/commands/permissions_test.go @@ -235,7 +235,18 @@ func (s *MmctlUnitTestSuite) TestResetPermissionsCmd() { Permissions: []string{"view_foos", "delete_bars"}, } - expectedPermissions := []string{"manage_channel_roles", "use_group_mentions"} + expectedPermissions := []string{ + "manage_channel_roles", + "use_group_mentions", + "add_bookmark_public_channel", + "edit_bookmark_public_channel", + "delete_bookmark_public_channel", + "order_bookmark_public_channel", + "add_bookmark_private_channel", + "edit_bookmark_private_channel", + "delete_bookmark_private_channel", + "order_bookmark_private_channel", + } expectedPatch := &model.RolePatch{ Permissions: &expectedPermissions, } diff --git a/server/i18n/en.json b/server/i18n/en.json index 2cffb542dc..33d17a040f 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -231,6 +231,58 @@ "id": "api.channel.add_user_to_channel.type.app_error", "translation": "Can not add user to this channel type." }, + { + "id": "api.channel.bookmark.channel_bookmark.license.error", + "translation": "Your license does not support channel bookmarks." + }, + { + "id": "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels.forbidden.app_error", + "translation": "User is not allowed to create a channel bookmark." + }, + { + "id": "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "Failed to create the channel bookmark." + }, + { + "id": "api.channel.bookmark.create_channel_bookmark.forbidden.app_error", + "translation": "Failed to create the channel bookmark." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels.forbidden.app_error", + "translation": "Failed to delete the channel bookmark." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "Failed to delete the channel bookmark." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.forbidden.app_error", + "translation": "Failed to delete the channel bookmark." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels.forbidden.app_error", + "translation": "Failed to update the channel bookmark." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "Failed to update the channel bookmark." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.forbidden.app_error", + "translation": "Failed to update the channel bookmark." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels.forbidden.app_error", + "translation": "Failed to update the channel bookmark's sort order." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "Failed to update the channel bookmark's sort order." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.forbidden.app_error", + "translation": "Failed to update the channel bookmark's sort order." + }, { "id": "api.channel.change_channel_privacy.private_to_public", "translation": "This channel has been converted to a Public Channel and can be joined by any team member." @@ -4638,6 +4690,38 @@ "id": "app.channel.autofollow.app_error", "translation": "Failed to update thread membership for mentioned user" }, + { + "id": "app.channel.bookmark.delete.app_error", + "translation": "Could not delete bookmark." + }, + { + "id": "app.channel.bookmark.get.app_error", + "translation": "Could not get bookmark." + }, + { + "id": "app.channel.bookmark.get_existing.app_err", + "translation": "Could not get existing bookmark to update." + }, + { + "id": "app.channel.bookmark.save.app_error", + "translation": "Could not save bookmark." + }, + { + "id": "app.channel.bookmark.update.app_error", + "translation": "Could not update bookmark." + }, + { + "id": "app.channel.bookmark.update_sort.app_error", + "translation": "Could not sort the bookmark." + }, + { + "id": "app.channel.bookmark.update_sort.invalid_input.app_error", + "translation": "Could not sort the bookmark. Invalid input." + }, + { + "id": "app.channel.bookmark.update_sort.missing_bookmark.app_error", + "translation": "Could not sort the bookmark. Not found." + }, { "id": "app.channel.clear_all_custom_role_assignments.select.app_error", "translation": "Failed to retrieve the channel members." @@ -8438,6 +8522,58 @@ "id": "model.channel.is_valid.update_at.app_error", "translation": "Update at must be a valid time." }, + { + "id": "model.channel_bookmark.is_valid.channel_id.app_error", + "translation": "Invalid channel id." + }, + { + "id": "model.channel_bookmark.is_valid.create_at.app_error", + "translation": "Create at must be a valid time." + }, + { + "id": "model.channel_bookmark.is_valid.display_name.app_error", + "translation": "Display name missing." + }, + { + "id": "model.channel_bookmark.is_valid.file_id.missing_or_invalid.app_error", + "translation": "File id is missing or invalid." + }, + { + "id": "model.channel_bookmark.is_valid.id.app_error", + "translation": "Invalid Id." + }, + { + "id": "model.channel_bookmark.is_valid.image_url.app_error", + "translation": "Invalid image url." + }, + { + "id": "model.channel_bookmark.is_valid.link_file.app_error", + "translation": "Cannot set a link and a file in the same bookmark." + }, + { + "id": "model.channel_bookmark.is_valid.link_url.missing_or_invalid.app_error", + "translation": "Link url is missing or invalid." + }, + { + "id": "model.channel_bookmark.is_valid.original_id.app_error", + "translation": "Invalid original id." + }, + { + "id": "model.channel_bookmark.is_valid.owner_id.app_error", + "translation": "Invalid owner id." + }, + { + "id": "model.channel_bookmark.is_valid.parent_id.app_error", + "translation": "Invalid parent id." + }, + { + "id": "model.channel_bookmark.is_valid.type.app_error", + "translation": "Invalid type." + }, + { + "id": "model.channel_bookmark.is_valid.update_at.app_error", + "translation": "Update at must be a valid time." + }, { "id": "model.channel_member.is_valid.channel_auto_follow_threads_value.app_error", "translation": "Invalid channel-auto-follow-threads value." diff --git a/server/public/model/channel_bookmark.go b/server/public/model/channel_bookmark.go new file mode 100644 index 0000000000..4d2a229cf2 --- /dev/null +++ b/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 +} diff --git a/server/public/model/channel_bookmark_test.go b/server/public/model/channel_bookmark_test.go new file mode 100644 index 0000000000..4186c69e70 --- /dev/null +++ b/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) +} diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 38f216ec99..947f9c1468 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -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 +} diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index c2f3ea4ce3..717205e84a 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -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 } diff --git a/server/public/model/file_info.go b/server/public/model/file_info.go index 1b51fed507..b906e5a535 100644 --- a/server/public/model/file_info.go +++ b/server/public/model/file_info.go @@ -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) } diff --git a/server/public/model/file_info_test.go b/server/public/model/file_info_test.go index 235397876f..9fcb53f4d2 100644 --- a/server/public/model/file_info_test.go +++ b/server/public/model/file_info_test.go @@ -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) { diff --git a/server/public/model/migration.go b/server/public/model/migration.go index 89c8d24094..542ec35c23 100644 --- a/server/public/model/migration.go +++ b/server/public/model/migration.go @@ -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" ) diff --git a/server/public/model/permission.go b/server/public/model/permission.go index c3252c8c24..39e3594a40 100644 --- a/server/public/model/permission.go +++ b/server/public/model/permission.go @@ -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() { diff --git a/server/public/model/role.go b/server/public/model/role.go index 454a91bb57..8b8619c539 100644 --- a/server/public/model/role.go +++ b/server/public/model/role.go @@ -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, diff --git a/server/public/model/websocket_message.go b/server/public/model/websocket_message.go index d3e3893202..44b3146d6c 100644 --- a/server/public/model/websocket_message.go +++ b/server/public/model/websocket_message.go @@ -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" ) diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx index 828414cfec..03c5fa7193 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/permissions_tree/permissions_tree.tsx @@ -208,6 +208,8 @@ export default class PermissionsTree extends React.PureComponent { const {config, scope, license} = this.props; const teamsGroup = this.groups[0]; + const publicChannelsGroup = this.groups[1]; + const privateChannelsGroup = this.groups[2]; const postsGroup = this.groups[7]; const integrationsGroup = this.groups[8]; const sharedChannelsGroup = this.groups[9]; @@ -257,6 +259,29 @@ export default class PermissionsTree extends React.PureComponent { customGroupsGroup?.permissions.pop(); } + if (license?.IsLicensed === 'true') { + publicChannelsGroup.permissions.push({ + id: 'manage_public_channel_bookmarks', + combined: true, + permissions: [ + Permissions.ADD_BOOKMARK_PUBLIC_CHANNEL, + Permissions.EDIT_BOOKMARK_PUBLIC_CHANNEL, + Permissions.DELETE_BOOKMARK_PUBLIC_CHANNEL, + Permissions.ORDER_BOOKMARK_PUBLIC_CHANNEL, + ], + }); + privateChannelsGroup.permissions.push({ + id: 'manage_private_channel_bookmarks', + combined: true, + permissions: [ + Permissions.ADD_BOOKMARK_PRIVATE_CHANNEL, + Permissions.EDIT_BOOKMARK_PRIVATE_CHANNEL, + Permissions.DELETE_BOOKMARK_PRIVATE_CHANNEL, + Permissions.ORDER_BOOKMARK_PRIVATE_CHANNEL, + ], + }); + } + this.groups = this.groups.filter((group) => { if (group.isVisible) { return group.isVisible(this.props.license); diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/groups.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/groups.tsx index e11716cbd6..3fd2993b38 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/groups.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/strings/groups.tsx @@ -255,4 +255,24 @@ export const groupRolesStrings: Record defaultMessage: 'Create, edit, delete and manage the members of custom groups.', }, }), + manage_public_channel_bookmarks: defineMessages({ + name: { + id: 'admin.permissions.group.manage_public_channel_bookmarks.name', + defaultMessage: 'Manage Bookmarks', + }, + description: { + id: 'admin.permissions.group.manage_public_channel_bookmarks.description', + defaultMessage: 'Add, edit, delete and sort bookmarks', + }, + }), + manage_private_channel_bookmarks: defineMessages({ + name: { + id: 'admin.permissions.group.manage_private_channel_bookmarks.name', + defaultMessage: 'Manage Bookmarks', + }, + description: { + id: 'admin.permissions.group.manage_private_channel_bookmarks.description', + defaultMessage: 'Add, edit, delete and sort bookmarks', + }, + }), }; diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_moderation.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_moderation.tsx index 33bec226a6..8316b3570e 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_moderation.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_moderation.tsx @@ -27,6 +27,8 @@ const MEMBERS_CAN_MANAGE_CHANNEL_MEMBERS_PERMISSION = 'manage_{public_or_private const GUESTS_CAN_MANAGE_CHANNEL_MEMBERS_PERMISSION = 'guest_manage_{public_or_private}_channel_members'; const MEMBERS_CAN_USE_CHANNEL_MENTIONS_PERMISSION = 'use_channel_mentions'; const GUESTS_CAN_USE_CHANNEL_MENTIONS_PERMISSION = 'guest_use_channel_mentions'; +const MEMBERS_CAN_MANAGE_CHANNEL_BOOKMARKS_PERMISSION = 'manage_{public_or_private}_channel_bookmarks'; +const GUESTS_CAN_MANAGE_CHANNEL_BOOKMARKS_PERMISSION = 'guest_manage_{public_or_private}_channel_bookmarks'; function getChannelModerationPermissionNames(permission: string) { if (permission === Permissions.CHANNEL_MODERATED_PERMISSIONS.CREATE_POST) { @@ -61,6 +63,14 @@ function getChannelModerationPermissionNames(permission: string) { }; } + if (permission === Permissions.CHANNEL_MODERATED_PERMISSIONS.MANAGE_BOOKMARKS) { + return { + disabledGuests: GUESTS_CAN_MANAGE_CHANNEL_BOOKMARKS_PERMISSION, + disabledMembers: MEMBERS_CAN_MANAGE_CHANNEL_BOOKMARKS_PERMISSION, + disabledBoth: MEMBERS_CAN_MANAGE_CHANNEL_BOOKMARKS_PERMISSION, + }; + } + return null; } @@ -181,6 +191,29 @@ function getChannelModerationRowsMessages(permission: string): Record