mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-11 14:54:34 -05:00
Compare commits
45 commits
master
...
v11.3.1-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d4b795e32 | ||
|
|
e8c9f34e69 | ||
|
|
666644d003 | ||
|
|
59272d115b | ||
|
|
1fe1049198 | ||
|
|
a296621e3b | ||
|
|
6edc7d4bc8 | ||
|
|
71f013648a | ||
|
|
21220ccfec | ||
|
|
44bb4c2db6 | ||
|
|
2588c47b20 | ||
|
|
523c566727 | ||
|
|
d27a219506 | ||
|
|
3051ead599 | ||
|
|
c70e184b65 | ||
|
|
4356b092dd | ||
|
|
2d6b2ae112 | ||
|
|
6145da452c | ||
|
|
69b019d53e | ||
|
|
cd33bc9067 | ||
|
|
e260ac346f | ||
|
|
6f9b21c68e | ||
|
|
8567622504 | ||
|
|
378804d0df | ||
|
|
95b9160472 | ||
|
|
2a83ca9646 | ||
|
|
d5529dcc9a | ||
|
|
3bb469082e | ||
|
|
7cd53beea6 | ||
|
|
a912d1177b | ||
|
|
a5a0e18064 | ||
|
|
c7bebc1c81 | ||
|
|
9cf621f640 | ||
|
|
a73f912669 | ||
|
|
f5385514df | ||
|
|
636486dc56 | ||
|
|
9dbe20f9ab | ||
|
|
682534dea1 | ||
|
|
60c65c95b9 | ||
|
|
0a2f0c4e81 | ||
|
|
fcc81d962b | ||
|
|
5eb7b7acd8 | ||
|
|
b3b1005f09 | ||
|
|
b65dbf4434 | ||
|
|
b7bf0ec9f6 |
271 changed files with 13207 additions and 1854 deletions
37
NOTICE.txt
37
NOTICE.txt
|
|
@ -9692,41 +9692,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||
SOFTWARE.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## oov/psd
|
||||
|
||||
This product contains 'psd' by oov.
|
||||
|
||||
A PSD/PSB file reader for go
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/oov/psd
|
||||
|
||||
* LICENSE: MIT
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016 oov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## opensearch-project/opensearch-go
|
||||
|
|
@ -13048,5 +13013,3 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1162,6 +1162,109 @@
|
|||
"501":
|
||||
$ref: "#/components/responses/NotImplemented"
|
||||
|
||||
"/api/v4/posts/{post_id}/reveal":
|
||||
get:
|
||||
tags:
|
||||
- posts
|
||||
summary: Reveal a burn-on-read post
|
||||
description: >
|
||||
Reveal a burn-on-read post. This endpoint allows a user to reveal a post
|
||||
that was created with burn-on-read functionality. Once revealed, the post
|
||||
content becomes visible to the user. If the post is already revealed and
|
||||
not expired, this is a no-op. If the post has expired, an error will be returned.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must have `read_channel` permission for the channel the post is in.<br/>
|
||||
Must be a member of the channel the post is in.<br/>
|
||||
Cannot reveal your own post.
|
||||
|
||||
##### Feature Flag
|
||||
|
||||
Requires `BurnOnRead` feature flag and Enterprise Advanced license.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: RevealPost
|
||||
parameters:
|
||||
- name: post_id
|
||||
in: path
|
||||
description: The identifier of the post to reveal
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Post revealed successfully
|
||||
headers:
|
||||
Has-Inaccessible-Posts:
|
||||
schema:
|
||||
type: boolean
|
||||
description: This header is included with the value "true" if the post is past the cloud's plan limit.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Post"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplemented"
|
||||
|
||||
"/api/v4/posts/{post_id}/burn":
|
||||
delete:
|
||||
tags:
|
||||
- posts
|
||||
summary: Burn a burn-on-read post
|
||||
description: >
|
||||
Burn a burn-on-read post. This endpoint allows a user to burn a post that
|
||||
was created with burn-on-read functionality. If the user is the author of
|
||||
the post, the post will be permanently deleted. If the user is not the author,
|
||||
the post will be expired for that user by updating their read receipt expiration
|
||||
time. If the user has not revealed the post yet, an error will be returned.
|
||||
If the post is already expired for the user, this is a no-op.
|
||||
|
||||
##### Permissions
|
||||
|
||||
Must have `read_channel` permission for the channel the post is in.<br/>
|
||||
Must be a member of the channel the post is in.
|
||||
|
||||
##### Feature Flag
|
||||
|
||||
Requires `BurnOnRead` feature flag and Enterprise Advanced license.
|
||||
|
||||
__Minimum server version__: 11.2
|
||||
operationId: BurnPost
|
||||
parameters:
|
||||
- name: post_id
|
||||
in: path
|
||||
description: The identifier of the post to burn
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Post burned successfully
|
||||
headers:
|
||||
Has-Inaccessible-Posts:
|
||||
schema:
|
||||
type: boolean
|
||||
description: This header is included with the value "true" if the post is past the cloud's plan limit.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StatusOK"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplemented"
|
||||
|
||||
"/api/v4/posts/rewrite":
|
||||
post:
|
||||
tags:
|
||||
|
|
|
|||
|
|
@ -202,12 +202,12 @@ describe('Verify Accessibility Support in different input fields', () => {
|
|||
cy.get('#FormattingControl_ul').should('be.focused').and('have.attr', 'aria-label', 'bulleted list').tab();
|
||||
|
||||
// * Verify if the focus is on the numbered list button
|
||||
cy.get('#FormattingControl_ol').should('be.focused').and('have.attr', 'aria-label', 'numbered list').tab().tab();
|
||||
cy.get('#FormattingControl_ol').should('be.focused').and('have.attr', 'aria-label', 'numbered list').tab().tab().tab();
|
||||
|
||||
// * Verify if the focus is on the formatting options button
|
||||
cy.get('#toggleFormattingBarButton').should('be.focused').and('have.attr', 'aria-label', 'formatting').tab();
|
||||
|
||||
// * Verify if the focus is on the attachment icon
|
||||
// * Verify if the focus is on the attachment icon (skipping burn-on-read button when enabled)
|
||||
cy.get('#fileUploadButton').should('be.focused').and('have.attr', 'aria-label', 'attachment').tab();
|
||||
|
||||
// * Verify if the focus is on the emoji picker
|
||||
|
|
|
|||
|
|
@ -134,9 +134,9 @@ describe('Guest Account - Guest User Invitation Flow', () => {
|
|||
cy.findByText('Update email').should('be.visible').click();
|
||||
|
||||
// * Update email outside whitelisted domain and verify error message
|
||||
cy.findByTestId('resetEmailModal').should('be.visible').within(() => {
|
||||
cy.findByTestId('resetEmailForm').should('be.visible').get('input').type(email);
|
||||
cy.findByTestId('resetEmailButton').click();
|
||||
cy.get('#resetEmailModal').should('be.visible').within(() => {
|
||||
cy.get('input[type="email"]').type(email);
|
||||
cy.get('button.btn-primary.confirm').click();
|
||||
cy.get('.error').should('be.visible').and('have.text', 'The email you provided does not belong to an accepted domain for guest accounts. Please contact your administrator or sign up with a different email.');
|
||||
cy.get('.close').click();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,9 +85,9 @@ describe('Guest Account - Verify Manage Guest Users', () => {
|
|||
|
||||
// * Update email of Guest User
|
||||
const email = `temp-${getRandomId()}@mattermost.com`;
|
||||
cy.findByTestId('resetEmailModal').should('be.visible').within(() => {
|
||||
cy.findByTestId('resetEmailForm').should('be.visible').get('input').type(email);
|
||||
cy.findByTestId('resetEmailButton').click();
|
||||
cy.get('#resetEmailModal').should('be.visible').within(() => {
|
||||
cy.get('input[type="email"]').type(email);
|
||||
cy.get('button.btn-primary.confirm').click();
|
||||
});
|
||||
|
||||
// * Verify if Guest's email was updated
|
||||
|
|
|
|||
|
|
@ -92,18 +92,6 @@ describe('Upload Files - Image', () => {
|
|||
testImage(properties);
|
||||
});
|
||||
|
||||
it('MM-T2264_6 - PSD', () => {
|
||||
const properties = {
|
||||
filePath: 'mm_file_testing/Images/PSD.psd',
|
||||
fileName: 'PSD.psd',
|
||||
originalWidth: 400,
|
||||
originalHeight: 479,
|
||||
mimeType: 'application/psd',
|
||||
};
|
||||
|
||||
testImage(properties);
|
||||
});
|
||||
|
||||
it('MM-T2264_7 - WEBP', () => {
|
||||
const properties = {
|
||||
filePath: 'mm_file_testing/Images/WEBP.webp',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ describe('Message', () => {
|
|||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// # Close any open modals from previous tests (e.g., move-thread-modal)
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('.modal.in').length > 0) {
|
||||
cy.get('body').type('{esc}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T77 Consecutive message does not repeat profile info', () => {
|
||||
// # Wait for posts to load
|
||||
cy.get('#postListContent').should('be.visible');
|
||||
|
|
@ -69,7 +78,14 @@ describe('Message', () => {
|
|||
|
||||
// # Open the "..." menu on a post in the main to move the focus out of the main input box
|
||||
cy.clickPostDotMenu(postId);
|
||||
cy.get(`#CENTER_dropdown_${postId}`).should('be.visible').type('{esc}');
|
||||
cy.get(`#CENTER_dropdown_${postId}`).should('be.visible');
|
||||
|
||||
// # Press ESC on body to close the menu (more reliable than typing on dropdown)
|
||||
cy.get('body').type('{esc}');
|
||||
|
||||
// # Wait for menu to close and ensure no modals are open
|
||||
cy.get(`#CENTER_dropdown_${postId}`).should('not.exist');
|
||||
cy.get('.modal.in').should('not.exist');
|
||||
|
||||
// # Push a character key such as "A"
|
||||
cy.uiGetPostTextBox().type('A');
|
||||
|
|
|
|||
|
|
@ -75,8 +75,12 @@ describe('Move Thread', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
// # Go to 1. public channel
|
||||
cy.visit(`/${testTeam.name}/channels/${dmChannel.name}`);
|
||||
// # Close any open modals to prevent test pollution
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('.modal.in').length > 0) {
|
||||
cy.get('body').type('{esc}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T5512_1 Move root post from DM', () => {
|
||||
|
|
|
|||
|
|
@ -86,8 +86,12 @@ describe('Move thread', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
// # Go to 1. public channel
|
||||
cy.visit(`/${testTeam.name}/channels/${gmChannel.name}`);
|
||||
// # Close any open modals to prevent test pollution
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('.modal.in').length > 0) {
|
||||
cy.get('body').type('{esc}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T5514_1 Move post from GM (with at least 2 other users)', () => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ describe('Move thread', () => {
|
|||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// # Close any open modals to prevent test pollution
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('.modal.in').length > 0) {
|
||||
cy.get('body').type('{esc}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T5511_1 Move root post from private channel', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
|
|
|||
|
|
@ -101,6 +101,15 @@ describe('Move Thread', () => {
|
|||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// # Close any open modals to prevent test pollution
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('.modal.in').length > 0) {
|
||||
cy.get('body').type('{esc}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('MM-T5514 Move root post from public channel to another public channel', () => {
|
||||
// # Check if ... button is visible in last post right side
|
||||
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
|
||||
|
|
@ -223,6 +232,9 @@ describe('Move Thread', () => {
|
|||
|
||||
// * Assert Notification is shown
|
||||
cy.findByTestId('notification-text').should('be.visible').should('contain.text', 'Moving this thread changes who has access');
|
||||
|
||||
// # Click confirm to close the modal and complete the move
|
||||
cy.get('.GenericModal__button.confirm').click();
|
||||
});
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe('System Console > User Management > Users', () => {
|
|||
|
||||
// # Type new password and submit.
|
||||
cy.get('input[type=password]').type('new' + testUser.password);
|
||||
cy.get('button[type=submit]').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// # Log out.
|
||||
cy.apiLogout();
|
||||
|
|
@ -137,10 +137,10 @@ describe('System Console > User Management > Users', () => {
|
|||
cy.get('input[type=password]').eq(1).type('new' + otherAdmin.password);
|
||||
|
||||
// # Click the 'Reset' button.
|
||||
cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// * Verify the appropriate error is returned.
|
||||
cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible').
|
||||
// * Verify the appropriate error is returned (current password error shows in modal header area).
|
||||
cy.get('.genericModalError .error').should('be.visible').
|
||||
and('contain', 'The "Current Password" you entered is incorrect. Please check that Caps Lock is off and try again.');
|
||||
});
|
||||
|
||||
|
|
@ -160,11 +160,10 @@ describe('System Console > User Management > Users', () => {
|
|||
cy.get('input[type=password]').eq(1).type('new');
|
||||
|
||||
// # Click the 'Reset' button.
|
||||
cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// * Verify the appropriate error is returned.
|
||||
cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible').
|
||||
and('contain', 'Your password must be 5-72 characters long.');
|
||||
// * Verify the appropriate error is returned (new password error shows under the input).
|
||||
cy.get('.Input___error').should('be.visible').and('contain', 'characters long');
|
||||
});
|
||||
|
||||
it('MM-T936 Users - System admin changes own password - Blank fields', () => {
|
||||
|
|
@ -179,21 +178,20 @@ describe('System Console > User Management > Users', () => {
|
|||
cy.findByText('Reset password').click();
|
||||
|
||||
// # Click the 'Reset' button.
|
||||
cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// * Verify the appropriate error is returned.
|
||||
cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible').
|
||||
// * Verify the appropriate error is returned (current password missing).
|
||||
cy.get('.genericModalError .error').should('be.visible').
|
||||
and('contain', 'Please enter your current password.');
|
||||
|
||||
// # Type current password, leave new password blank.
|
||||
cy.get('input[type=password]').eq(0).type(otherAdmin.password);
|
||||
|
||||
// # Click the 'Reset' button.
|
||||
cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// * Verify the appropriate error is returned.
|
||||
cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible').
|
||||
and('contain', 'Your password must be 5-72 characters long.');
|
||||
// * Verify the appropriate error is returned (new password error shows under the input).
|
||||
cy.get('.Input___error').should('be.visible').and('contain', 'characters long');
|
||||
});
|
||||
|
||||
it('MM-T937 Users - System admin changes own password - Successfully changed', () => {
|
||||
|
|
@ -212,7 +210,7 @@ describe('System Console > User Management > Users', () => {
|
|||
cy.get('input[type=password]').eq(1).type('new' + otherAdmin.password);
|
||||
|
||||
// # Click the 'Reset' button.
|
||||
cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// # Log out.
|
||||
cy.apiLogout();
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ describe('User Management', () => {
|
|||
|
||||
// # Set new password.
|
||||
cy.get('input[type=password]').type('new' + testUser.password);
|
||||
cy.get('button[type=submit]').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('button.btn-primary.confirm').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// * Verify Update email option is visible.
|
||||
cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
|
@ -262,22 +262,22 @@ describe('User Management', () => {
|
|||
cy.findByText('Update email').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// # Verify the modal opened.
|
||||
cy.findByTestId('resetEmailModal').should('exist');
|
||||
cy.get('#resetEmailModal').should('exist');
|
||||
|
||||
// # Type the new e-mail address.
|
||||
if (newEmail.length > 0) {
|
||||
cy.get('input[type=email]').eq(0).clear().type(newEmail);
|
||||
}
|
||||
|
||||
// # Click the "Reset" button.
|
||||
cy.findByTestId('resetEmailButton').click();
|
||||
// # Click the "Update" button.
|
||||
cy.get('button.btn-primary.confirm').click();
|
||||
|
||||
// * Check for the error messages, if any.
|
||||
if (errorMsg.length > 0) {
|
||||
cy.get('form.form-horizontal').find('.has-error p.error').should('be.visible').and('contain', errorMsg);
|
||||
cy.get('.Input___error').should('be.visible').and('contain', errorMsg);
|
||||
|
||||
// # Close the modal.
|
||||
cy.findByLabelText('Close').click();
|
||||
cy.get('button.close').click();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,4 +85,8 @@ export default class SystemConsolePage {
|
|||
async clickResetButton() {
|
||||
await this.saveChangesModal.container.locator('button.btn-primary:has-text("Reset")').click();
|
||||
}
|
||||
|
||||
async clickUpdateEmailButton() {
|
||||
await this.saveChangesModal.container.locator('button.btn-primary:has-text("Update")').click();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,10 +172,10 @@ test('MM-T5520-5 should change the users email', async ({pw}) => {
|
|||
const updateEmail = await systemConsolePage.systemUsersActionMenus[0].getMenuItem('Update email');
|
||||
await updateEmail.click();
|
||||
|
||||
// # Enter a random password and click Save
|
||||
// # Enter new email and click Update
|
||||
const emailInput = systemConsolePage.page.locator('input[type="email"]');
|
||||
await emailInput.fill(newEmail);
|
||||
await systemConsolePage.clickResetButton();
|
||||
await systemConsolePage.clickUpdateEmailButton();
|
||||
|
||||
// * Verify that the modal closed
|
||||
await emailInput.waitFor({state: 'detached'});
|
||||
|
|
|
|||
|
|
@ -155,12 +155,12 @@ PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:)
|
|||
PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-github-v2.5.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.11.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.4.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.6.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.10.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.6.2
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.11.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.2
|
||||
PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1
|
||||
PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.5.0
|
||||
PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.3.0
|
||||
|
|
@ -173,7 +173,7 @@ PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.3.0
|
|||
# the way we pre-package FIPS and non-FIPS plugins.
|
||||
ifeq ($(FIPS_ENABLED),true)
|
||||
PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.6.1%2B0e01d28-fips
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.6.2%2B66117c7-fips
|
||||
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2%2B866e2dd-fips
|
||||
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.1%2Bdf49b26-fips
|
||||
endif
|
||||
|
||||
|
|
|
|||
|
|
@ -1060,6 +1060,23 @@ func (th *TestHelper) TestForAllClients(t *testing.T, f func(*testing.T, *model.
|
|||
})
|
||||
}
|
||||
|
||||
// TestForRegularAndSystemAdminClients runs a test function for regular and system admin the clients
|
||||
// registered in the TestHelper
|
||||
func (th *TestHelper) TestForRegularAndSystemAdminClients(t *testing.T, f func(*testing.T, *model.Client4), name ...string) {
|
||||
var testName string
|
||||
if len(name) > 0 {
|
||||
testName = name[0] + "/"
|
||||
}
|
||||
|
||||
t.Run(testName+"Client", func(t *testing.T) {
|
||||
f(t, th.Client)
|
||||
})
|
||||
|
||||
t.Run(testName+"SystemAdminClient", func(t *testing.T) {
|
||||
f(t, th.SystemAdminClient)
|
||||
})
|
||||
}
|
||||
|
||||
func GenerateTestUsername() string {
|
||||
return "fakeuser" + model.NewRandomString(10)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2462,7 +2462,7 @@ func getDirectOrGroupMessageMembersCommonTeams(c *Context, w http.ResponseWriter
|
|||
return
|
||||
}
|
||||
|
||||
teams, appErr := c.App.GetDirectOrGroupMessageMembersCommonTeams(c.AppContext, c.Params.ChannelId)
|
||||
teams, appErr := c.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(c.AppContext, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
|
|
|
|||
180
server/channels/api4/channel_common_teams_test.go
Normal file
180
server/channels/api4/channel_common_teams_test.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetDirectOrGroupMessageMembersCommonTeams(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
||||
t.Run("requires authentication", func(t *testing.T) {
|
||||
user1 := th.BasicUser
|
||||
user2 := th.BasicUser2
|
||||
|
||||
testClient := th.CreateClient()
|
||||
_, _, err := testClient.Login(context.Background(), user1.Email, user1.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
dmChannel, _, err := testClient.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = testClient.Logout(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := testClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.Error(t, err)
|
||||
CheckUnauthorizedStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("forbids guest users", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.GuestAccountsSettings.Enable = true
|
||||
})
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
|
||||
guestUser, guestClient := th.CreateGuestAndClient(t)
|
||||
team1 := th.BasicTeam
|
||||
th.LinkUserToTeam(t, guestUser, team1)
|
||||
|
||||
user2 := th.BasicUser2
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
|
||||
dmChannel, _, err := th.SystemAdminClient.CreateDirectChannel(context.Background(), guestUser.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := guestClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("requires read permission on channel", func(t *testing.T) {
|
||||
user1 := th.CreateUser(t)
|
||||
user2 := th.CreateUser(t)
|
||||
team1 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
|
||||
dmChannel, _, err := th.SystemAdminClient.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
otherUser := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, otherUser, team1)
|
||||
|
||||
otherClient := th.CreateClient()
|
||||
_, _, err = otherClient.Login(context.Background(), otherUser.Email, otherUser.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := otherClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("returns bad request for non-DM/GM channel", func(t *testing.T) {
|
||||
testClient := th.CreateClient()
|
||||
_, _, err := testClient.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := testClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), th.BasicChannel.Id)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("returns common teams for DM channel members", func(t *testing.T) {
|
||||
user1 := th.BasicUser
|
||||
user2 := th.BasicUser2
|
||||
team1 := th.BasicTeam
|
||||
team2 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user1, team2)
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
|
||||
dmChannel, _, err := client.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
teams, _, err := client.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, teams, 1, "should only return team1 since user2 is not in team2")
|
||||
assert.Equal(t, team1.Id, teams[0].Id)
|
||||
})
|
||||
|
||||
t.Run("returns common teams for GM channel members", func(t *testing.T) {
|
||||
user1 := th.BasicUser
|
||||
user2 := th.BasicUser2
|
||||
user3 := th.CreateUser(t)
|
||||
team1 := th.BasicTeam
|
||||
team2 := th.CreateTeam(t)
|
||||
team3 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user1, team2)
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
th.LinkUserToTeam(t, user2, team3)
|
||||
th.LinkUserToTeam(t, user3, team1)
|
||||
|
||||
gmChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user1.Id, user2.Id, user3.Id})
|
||||
require.NoError(t, err)
|
||||
|
||||
teams, _, err := client.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), gmChannel.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, teams, 1, "should only return team1 since it's the only team all three users share")
|
||||
assert.Equal(t, team1.Id, teams[0].Id)
|
||||
})
|
||||
|
||||
t.Run("returns empty list when requesting user in channel but has no common teams with other members", func(t *testing.T) {
|
||||
user1 := th.CreateUser(t)
|
||||
user2 := th.CreateUser(t)
|
||||
team1 := th.CreateTeam(t)
|
||||
team2 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user2, team2)
|
||||
|
||||
testClient := th.CreateClient()
|
||||
_, _, err := testClient.Login(context.Background(), user1.Email, user1.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
dmChannel, _, err := testClient.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
teams, _, err := testClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, teams)
|
||||
})
|
||||
|
||||
t.Run("filters teams to only those common with requesting user", func(t *testing.T) {
|
||||
user1 := th.CreateUser(t)
|
||||
user2 := th.CreateUser(t)
|
||||
user3 := th.BasicUser
|
||||
team1 := th.CreateTeam(t)
|
||||
team2 := th.CreateTeam(t)
|
||||
team3 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user1, team2)
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
th.LinkUserToTeam(t, user2, team3)
|
||||
th.LinkUserToTeam(t, user3, team1)
|
||||
th.LinkUserToTeam(t, user3, team3)
|
||||
|
||||
gmChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user1.Id, user2.Id, user3.Id})
|
||||
require.NoError(t, err)
|
||||
|
||||
teams, _, err := client.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), gmChannel.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, teams, 1)
|
||||
assert.Equal(t, team1.Id, teams[0].Id)
|
||||
})
|
||||
}
|
||||
|
|
@ -159,6 +159,9 @@ func updateConfig(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
// modifications to the slice.
|
||||
cfg.PluginSettings.SignaturePublicKeyFiles = appCfg.PluginSettings.SignaturePublicKeyFiles
|
||||
|
||||
// Do not allow import directory to be changed through the API
|
||||
*cfg.ImportSettings.Directory = *appCfg.ImportSettings.Directory
|
||||
|
||||
// Do not allow marketplace URL to be toggled through the API if EnableUploads are disabled.
|
||||
if cfg.PluginSettings.EnableUploads != nil && !*appCfg.PluginSettings.EnableUploads {
|
||||
*cfg.PluginSettings.MarketplaceURL = *appCfg.PluginSettings.MarketplaceURL
|
||||
|
|
@ -305,6 +308,12 @@ func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Do not allow import directory to be changed through the API
|
||||
if cfg.ImportSettings.Directory != nil && *cfg.ImportSettings.Directory != *appCfg.ImportSettings.Directory {
|
||||
c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "ImportSettings.Directory"}, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Do not allow marketplace URL to be toggled if plugin uploads are disabled.
|
||||
if cfg.PluginSettings.MarketplaceURL != nil && cfg.PluginSettings.EnableUploads != nil {
|
||||
// Breaking it down to 2 conditions to make it simple.
|
||||
|
|
|
|||
|
|
@ -305,6 +305,43 @@ func TestUpdateConfig(t *testing.T) {
|
|||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("Should not be able to modify ImportSettings.Directory", func(t *testing.T) {
|
||||
t.Run("sysadmin", func(t *testing.T) {
|
||||
oldDirectory := *th.App.Config().ImportSettings.Directory
|
||||
cfg2 := th.App.Config().Clone()
|
||||
*cfg2.ImportSettings.Directory = "./new-import-dir"
|
||||
|
||||
cfg2, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldDirectory, *cfg2.ImportSettings.Directory)
|
||||
assert.Equal(t, oldDirectory, *th.App.Config().ImportSettings.Directory)
|
||||
|
||||
cfg2.ImportSettings.Directory = nil
|
||||
cfg2, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldDirectory, *cfg2.ImportSettings.Directory)
|
||||
assert.Equal(t, oldDirectory, *th.App.Config().ImportSettings.Directory)
|
||||
})
|
||||
|
||||
t.Run("local mode", func(t *testing.T) {
|
||||
oldDirectory := *th.App.Config().ImportSettings.Directory
|
||||
cfg2 := th.App.Config().Clone()
|
||||
newDirectory := "./new-import-dir"
|
||||
*cfg2.ImportSettings.Directory = newDirectory
|
||||
|
||||
cfg2, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newDirectory, *cfg2.ImportSettings.Directory)
|
||||
assert.Equal(t, newDirectory, *th.App.Config().ImportSettings.Directory)
|
||||
|
||||
cfg2.ImportSettings.Directory = nil
|
||||
cfg2, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldDirectory, *cfg2.ImportSettings.Directory)
|
||||
assert.Equal(t, oldDirectory, *th.App.Config().ImportSettings.Directory)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("System Admin should not be able to clear Site URL", func(t *testing.T) {
|
||||
siteURL := cfg.ServiceSettings.SiteURL
|
||||
defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = siteURL })
|
||||
|
|
@ -819,6 +856,30 @@ func TestPatchConfig(t *testing.T) {
|
|||
CheckForbiddenStatus(t, resp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not allowing to change import directory via api, unless local mode", func(t *testing.T) {
|
||||
oldDirectory := *th.App.Config().ImportSettings.Directory
|
||||
config := model.Config{ImportSettings: model.ImportSettings{
|
||||
Directory: model.NewPointer("./new-import-dir"),
|
||||
}}
|
||||
|
||||
updatedConfig, resp, err := client.PatchConfig(context.Background(), &config)
|
||||
if client == th.LocalClient {
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
assert.Equal(t, "./new-import-dir", *updatedConfig.ImportSettings.Directory)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
}
|
||||
|
||||
// Reset for local mode
|
||||
if client == th.LocalClient {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ImportSettings.Directory = oldDirectory
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Should not be able to modify PluginSettings.MarketplaceURL if EnableUploads is disabled", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -175,6 +175,11 @@ func flagPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
checkPostTypeFlaggable(c, post)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
|
|
@ -598,3 +603,9 @@ func assignFlaggedPostReviewer(c *Context, w http.ResponseWriter, r *http.Reques
|
|||
auditRec.Success()
|
||||
writeOKResponse(w)
|
||||
}
|
||||
|
||||
func checkPostTypeFlaggable(c *Context, post *model.Post) {
|
||||
if post.Type == model.PostTypeBurnOnRead || strings.HasPrefix(post.Type, model.PostSystemMessagePrefix) {
|
||||
c.Err = model.NewAppError("checkPostTypeFlaggable", "api.content_flagging.error.invalid_post_type", map[string]any{"PostType": post.Type}, "", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package api4
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
|
|
@ -416,6 +417,10 @@ func TestGetFlaggedPost(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFlagPost(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
|
||||
})
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
client := th.Client
|
||||
|
|
@ -554,6 +559,36 @@ func TestFlagPost(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Should not allow flagging a burn on read post", func(t *testing.T) {
|
||||
enableBurnOnReadFeature(th)
|
||||
defer th.RemoveLicense(t)
|
||||
|
||||
th.App.UpdateConfig(func(config *model.Config) {
|
||||
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
|
||||
config.ContentFlaggingSettings.SetDefaults()
|
||||
})
|
||||
|
||||
post := &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "This is a burn on read post",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
|
||||
createdPost, response, err := client.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
|
||||
flagRequest := &model.FlagContentRequest{
|
||||
Reason: "spam",
|
||||
Comment: "This is spam content",
|
||||
}
|
||||
|
||||
response, err = client.FlagPostForContentReview(context.Background(), createdPost.Id, flagRequest)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, response)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/web"
|
||||
)
|
||||
|
||||
|
|
@ -51,6 +52,8 @@ func (api *API) InitPost() {
|
|||
api.BaseRoutes.Post.Handle("/move", api.APISessionRequired(moveThread)).Methods(http.MethodPost)
|
||||
|
||||
api.BaseRoutes.Posts.Handle("/rewrite", api.APISessionRequired(rewriteMessage)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.Post.Handle("/reveal", api.APISessionRequired(revealPost)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Post.Handle("/burn", api.APISessionRequired(burnPost)).Methods(http.MethodDelete)
|
||||
}
|
||||
|
||||
func createPostChecks(where string, c *Context, post *model.Post) {
|
||||
|
|
@ -65,6 +68,13 @@ func createPostChecks(where string, c *Context, post *model.Post) {
|
|||
return
|
||||
}
|
||||
|
||||
if len(post.FileIds) > 0 {
|
||||
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionUploadFile) {
|
||||
c.SetPermissionError(model.PermissionUploadFile)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
postHardenedModeCheckWithContext(where, c, post.GetProps())
|
||||
if c.Err != nil {
|
||||
return
|
||||
|
|
@ -126,6 +136,27 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
// Note that rp has already had PreparePostForClient called on it by App.CreatePost
|
||||
// For burn-on-read posts, the author should see the revealed content in the API response
|
||||
// to avoid relying on websocket events which may fail due to connection issues
|
||||
if rp.Type == model.PostTypeBurnOnRead && rp.UserId == c.AppContext.Session().UserId {
|
||||
// Force read from master DB to avoid replication delay issues in DB cluster environments.
|
||||
// Without this, the replica might not have the post yet, causing "not found" errors.
|
||||
masterCtx := sqlstore.RequestContextWithMaster(c.AppContext)
|
||||
revealedPost, appErr := c.App.GetSinglePost(masterCtx, rp.Id, false)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
// GetSinglePost calls RevealBurnOnReadPostsForUser which reveals the post for the author,
|
||||
// then PreparePostForClient adds metadata (reactions, files, embeds).
|
||||
rp = c.App.PreparePostForClient(masterCtx, revealedPost, &model.PreparePostForClientOpts{
|
||||
IsNewPost: true,
|
||||
})
|
||||
|
||||
// Send pending post ID back to client so it can update it in Redux store
|
||||
rp.PendingPostId = post.PendingPostId
|
||||
}
|
||||
|
||||
if err := rp.EncodeJSON(w); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
|
|
@ -264,8 +295,12 @@ func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set(model.HeaderEtagServer, etag)
|
||||
}
|
||||
|
||||
c.App.AddCursorIdsForPostList(list, afterPost, beforePost, since, page, perPage, collapsedThreads)
|
||||
clientPostList := c.App.PreparePostListForClient(c.AppContext, list)
|
||||
|
||||
// Calculate NextPostId and PrevPostId AFTER filtering (including BoR filtering)
|
||||
// to ensure they only reference posts that are actually in the response
|
||||
c.App.AddCursorIdsForPostList(clientPostList, afterPost, beforePost, since, page, perPage, collapsedThreads)
|
||||
|
||||
clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
|
@ -330,10 +365,12 @@ func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *ht
|
|||
}
|
||||
}
|
||||
|
||||
postList.NextPostId = c.App.GetNextPostIdFromPostList(postList, collapsedThreads)
|
||||
postList.PrevPostId = c.App.GetPrevPostIdFromPostList(postList, collapsedThreads)
|
||||
|
||||
clientPostList := c.App.PreparePostListForClient(c.AppContext, postList)
|
||||
|
||||
// Calculate NextPostId and PrevPostId AFTER filtering (including BoR filtering)
|
||||
// to ensure they only reference posts that are actually in the response
|
||||
clientPostList.NextPostId = c.App.GetNextPostIdFromPostList(clientPostList, collapsedThreads)
|
||||
clientPostList.PrevPostId = c.App.GetPrevPostIdFromPostList(clientPostList, collapsedThreads)
|
||||
clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
|
@ -366,11 +403,11 @@ func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request)
|
|||
var err *model.AppError
|
||||
|
||||
if channelId != "" {
|
||||
posts, err = c.App.GetFlaggedPostsForChannel(c.Params.UserId, channelId, c.Params.Page, c.Params.PerPage)
|
||||
posts, err = c.App.GetFlaggedPostsForChannel(c.AppContext, c.Params.UserId, channelId, c.Params.Page, c.Params.PerPage)
|
||||
} else if teamId != "" {
|
||||
posts, err = c.App.GetFlaggedPostsForTeam(c.Params.UserId, teamId, c.Params.Page, c.Params.PerPage)
|
||||
posts, err = c.App.GetFlaggedPostsForTeam(c.AppContext, c.Params.UserId, teamId, c.Params.Page, c.Params.PerPage)
|
||||
} else {
|
||||
posts, err = c.App.GetFlaggedPosts(c.Params.UserId, c.Params.Page, c.Params.PerPage)
|
||||
posts, err = c.App.GetFlaggedPosts(c.AppContext, c.Params.UserId, c.Params.Page, c.Params.PerPage)
|
||||
}
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
|
@ -858,6 +895,10 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// MM-67055: Strip client-supplied metadata.embeds to prevent spoofing.
|
||||
// This matches createPost behavior.
|
||||
post.SanitizeInput()
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventUpdatePost, model.AuditStatusFail)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "post", &post)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
|
|
@ -893,6 +934,12 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
post.FileIds = originalPost.FileIds
|
||||
}
|
||||
|
||||
// Check upload_file permission only if update is adding NEW files (not just keeping existing ones)
|
||||
checkUploadFilePermissionForNewFiles(c, post.FileIds, originalPost)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.AppContext.Session().UserId != originalPost.UserId {
|
||||
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditOthersPosts) {
|
||||
c.SetPermissionError(model.PermissionEditOthersPosts)
|
||||
|
|
@ -950,6 +997,19 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false)
|
||||
if err != nil {
|
||||
c.SetPermissionError(model.PermissionEditPost)
|
||||
return
|
||||
}
|
||||
|
||||
if post.FileIds != nil {
|
||||
checkUploadFilePermissionForNewFiles(c, *post.FileIds, originalPost)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
patchedPost, err := c.App.PatchPost(c.AppContext, c.Params.PostId, c.App.PostPatchWithProxyRemovedFromImageURLs(&post), nil)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
|
@ -1417,3 +1477,110 @@ func rewriteMessage(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func revealPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
if !c.App.Config().FeatureFlags.BurnOnRead {
|
||||
c.Err = model.NewAppError("revealPost", "api.post.reveal_post.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.AppContext.Session().UserId
|
||||
postId := c.Params.PostId
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventRevealPost, model.AuditStatusFail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
model.AddEventParameterToAuditRec(auditRec, "post_id", postId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", userId)
|
||||
|
||||
post, err := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
if err.Id == "app.post.cloud.get.app_error" {
|
||||
w.Header().Set(model.HeaderFirstInaccessiblePostTime, "1")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.App.GetChannelMember(c.AppContext, post.ChannelId, userId)
|
||||
if err != nil {
|
||||
if err.Id == "app.channel.get_member.missing.app_error" {
|
||||
c.Err = model.NewAppError("revealPost", "api.post.reveal_post.user_not_in_channel.app_error", nil, fmt.Sprintf("postId=%s", c.Params.PostId), http.StatusForbidden)
|
||||
} else {
|
||||
c.Err = err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if post.UserId == userId {
|
||||
c.Err = model.NewAppError("revealPost", "api.post.reveal_post.cannot_reveal_own_post.app_error", nil, fmt.Sprintf("postId=%s", c.Params.PostId), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// should reveal the post
|
||||
// if it's already revealed, it should be a no-op if the post is not expired yet
|
||||
// if it's expired, it should return an error
|
||||
revealedPost, err := c.App.RevealPost(c.AppContext, post, userId, connectionID)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(revealedPost)
|
||||
|
||||
if jsErr := revealedPost.EncodeJSON(w); jsErr != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(jsErr))
|
||||
}
|
||||
}
|
||||
|
||||
func burnPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequirePostId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
|
||||
userId := c.AppContext.Session().UserId
|
||||
postId := c.Params.PostId
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventBurnPost, model.AuditStatusFail)
|
||||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
model.AddEventParameterToAuditRec(auditRec, "post_id", postId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", userId)
|
||||
|
||||
post, err := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
if err.Id == "app.post.cloud.get.app_error" {
|
||||
w.Header().Set(model.HeaderFirstInaccessiblePostTime, "1")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.App.GetChannelMember(c.AppContext, post.ChannelId, userId)
|
||||
if err != nil {
|
||||
if err.Id == "app.channel.get_member.missing.app_error" {
|
||||
c.Err = model.NewAppError("burnPost", "api.post.burn_post.user_not_in_channel.app_error", nil, fmt.Sprintf("postId=%s", c.Params.PostId), http.StatusForbidden)
|
||||
} else {
|
||||
c.Err = err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = c.App.BurnPost(c.AppContext, post, userId, connectionID)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ import (
|
|||
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
|
||||
)
|
||||
|
||||
// Helper to enable feature with license
|
||||
func enableBurnOnReadFeature(th *TestHelper) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.ServiceSettings.EnableBurnOnRead = model.NewPointer(true)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreatePost(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
|
|
@ -277,6 +285,46 @@ func TestCreatePost(t *testing.T) {
|
|||
assert.Nil(t, rpost)
|
||||
})
|
||||
|
||||
t.Run("should prevent creating post with files when user lacks upload_file permission in target channel", func(t *testing.T) {
|
||||
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt")
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
|
||||
defer func() {
|
||||
th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
|
||||
}()
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "Test post with file",
|
||||
FileIds: model.StringArray{fileId},
|
||||
}
|
||||
rpost, resp, err := client.CreatePost(context.Background(), post)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
assert.Nil(t, rpost)
|
||||
})
|
||||
|
||||
t.Run("should allow creating post with files when user has upload_file permission", func(t *testing.T) {
|
||||
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt")
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "Test post with file",
|
||||
FileIds: model.StringArray{fileId},
|
||||
}
|
||||
rpost, resp, err := client.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
require.NotNil(t, rpost)
|
||||
assert.Contains(t, rpost.FileIds, fileId)
|
||||
})
|
||||
|
||||
t.Run("CreateAt should match the one provided in the request", func(t *testing.T) {
|
||||
post := basicPost()
|
||||
post.CreateAt = 123
|
||||
|
|
@ -1491,6 +1539,53 @@ func TestUpdatePost(t *testing.T) {
|
|||
assert.NotEqual(t, rpost3.Attachments(), rrupost3.Attachments())
|
||||
})
|
||||
|
||||
t.Run("should strip spoofed metadata embeds", func(t *testing.T) {
|
||||
// MM-67055: Verify that client-supplied metadata.embeds are stripped
|
||||
post := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: "test message " + model.NewId(),
|
||||
}
|
||||
createdPost, _, err := client.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to update with spoofed embed
|
||||
updatePost := &model.Post{
|
||||
Id: createdPost.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "updated message " + model.NewId(),
|
||||
Metadata: &model.PostMetadata{
|
||||
Embeds: []*model.PostEmbed{
|
||||
{
|
||||
Type: model.PostEmbedPermalink,
|
||||
Data: &model.PreviewPost{
|
||||
PostID: "spoofed-post-id",
|
||||
Post: &model.Post{
|
||||
Id: "spoofed-post-id",
|
||||
UserId: th.BasicUser2.Id,
|
||||
Message: "This is a spoofed message!",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
updatedPost, _, err := client.UpdatePost(context.Background(), createdPost.Id, updatePost)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify spoofed embed was stripped
|
||||
if updatedPost.Metadata != nil {
|
||||
assert.Empty(t, updatedPost.Metadata.Embeds, "spoofed embeds should be stripped")
|
||||
}
|
||||
|
||||
// Double-check by fetching the post
|
||||
fetchedPost, _, err := client.GetPost(context.Background(), createdPost.Id, "")
|
||||
require.NoError(t, err)
|
||||
if fetchedPost.Metadata != nil {
|
||||
assert.Empty(t, fetchedPost.Metadata.Embeds, "spoofed embeds should not be persisted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("change message, but post too old", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.PostEditTimeLimit = 1
|
||||
|
|
@ -1537,6 +1632,62 @@ func TestUpdatePost(t *testing.T) {
|
|||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("should prevent updating post with files when user lacks upload_file permission in target channel", func(t *testing.T) {
|
||||
postWithoutFiles, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Post without files",
|
||||
}, channel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt")
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
|
||||
defer func() {
|
||||
th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
|
||||
}()
|
||||
|
||||
updatePost := &model.Post{
|
||||
Id: postWithoutFiles.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Updated post with file",
|
||||
FileIds: model.StringArray{fileId},
|
||||
}
|
||||
updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
assert.Nil(t, updatedPost)
|
||||
})
|
||||
|
||||
t.Run("should allow updating post with files when user has upload_file permission", func(t *testing.T) {
|
||||
postWithoutFiles, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Post without files",
|
||||
}, channel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt")
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
fileId := fileResp.FileInfos[0].Id
|
||||
|
||||
updatePost := &model.Post{
|
||||
Id: postWithoutFiles.Id,
|
||||
ChannelId: channel.Id,
|
||||
Message: "Updated post with file",
|
||||
FileIds: model.StringArray{fileId},
|
||||
}
|
||||
updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, updatedPost)
|
||||
assert.Contains(t, updatedPost.FileIds, fileId)
|
||||
})
|
||||
|
||||
t.Run("logged out", func(t *testing.T) {
|
||||
_, err := client.Logout(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
|
@ -5490,3 +5641,548 @@ func TestRestorePostVersion(t *testing.T) {
|
|||
require.Nil(t, restoredPost)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRevealPost(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
|
||||
})
|
||||
|
||||
th := SetupEnterprise(t).InitBasic(t)
|
||||
|
||||
th.LinkUserToTeam(t, th.SystemAdminUser, th.BasicTeam)
|
||||
th.AddUserToChannel(t, th.SystemAdminUser, th.BasicChannel)
|
||||
|
||||
// Helper to create burn-on-read post
|
||||
createBurnOnReadPost := func(client *model.Client4, channel *model.Channel) *model.Post {
|
||||
post := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
|
||||
|
||||
createdPost, resp, err := client.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
require.NotNil(t, createdPost)
|
||||
return createdPost
|
||||
}
|
||||
|
||||
// Helper to create and login second user
|
||||
createSecondUser := func(channel *model.Channel) (*model.User, *model.Client4) {
|
||||
user2 := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, user2, th.BasicTeam)
|
||||
if channel != nil {
|
||||
th.AddUserToChannel(t, user2, channel)
|
||||
}
|
||||
client2 := th.CreateClient()
|
||||
_, _, err := client2.Login(context.Background(), user2.Email, user2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, err = client2.Logout(context.Background())
|
||||
require.NoError(t, err)
|
||||
})
|
||||
return user2, client2
|
||||
}
|
||||
|
||||
t.Run("feature not enabled, should still allow reveal", func(t *testing.T) {
|
||||
enableBurnOnReadFeature(th)
|
||||
post := createBurnOnReadPost(th.SystemAdminClient, th.BasicChannel)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.BurnOnRead = false
|
||||
})
|
||||
|
||||
revealedPost, resp, err := th.Client.RevealPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, revealedPost)
|
||||
require.Equal(t, post.Id, revealedPost.Id)
|
||||
require.Equal(t, "burn on read message", revealedPost.Message)
|
||||
require.NotNil(t, revealedPost.Metadata)
|
||||
require.NotZero(t, revealedPost.Metadata.ExpireAt)
|
||||
})
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
regularPost := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "regular message",
|
||||
}
|
||||
createdPost, resp, err := th.Client.CreatePost(context.Background(), regularPost)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
_, client2 := createSecondUser(th.BasicChannel)
|
||||
|
||||
revealedPost, resp, err := client2.RevealPost(context.Background(), createdPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
CheckErrorID(t, err, "app.reveal_post.not_burn_on_read.app_error")
|
||||
require.Nil(t, revealedPost)
|
||||
}, "reveal regular post")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
revealedPost, resp, err := th.Client.RevealPost(context.Background(), model.NewId())
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
require.Nil(t, revealedPost)
|
||||
}, "reveal non-existing post")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
post := createBurnOnReadPost(client, th.BasicChannel)
|
||||
|
||||
revealedPost, resp, err := client.RevealPost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
require.Nil(t, revealedPost)
|
||||
CheckErrorID(t, err, "api.post.reveal_post.cannot_reveal_own_post.app_error")
|
||||
}, "try reveal own post")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
_, client2 := createSecondUser(th.BasicChannel)
|
||||
|
||||
post := createBurnOnReadPost(client2, th.BasicChannel)
|
||||
|
||||
revealedPost, resp, err := client.RevealPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, revealedPost)
|
||||
require.Equal(t, post.Id, revealedPost.Id)
|
||||
require.Equal(t, "burn on read message", revealedPost.Message)
|
||||
require.NotNil(t, revealedPost.Metadata)
|
||||
require.NotZero(t, revealedPost.Metadata.ExpireAt)
|
||||
}, "reveal someone elses post")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
_, client2 := createSecondUser(th.BasicChannel)
|
||||
|
||||
createdPost, resp, err := client2.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
// Manually expire the post
|
||||
storePost, err := th.App.Srv().Store().Post().Get(th.Context, createdPost.Id, model.GetPostsOptions{}, "", th.App.Config().GetSanitizeOptions())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, storePost.Posts, 1)
|
||||
|
||||
postToUpdate := storePost.Posts[createdPost.Id]
|
||||
postToUpdate.AddProp(model.PostPropsExpireAt, model.GetMillis()-1000)
|
||||
_, err = th.App.Srv().Store().Post().Overwrite(th.Context, postToUpdate)
|
||||
require.NoError(t, err)
|
||||
|
||||
revealedPost, resp, err := client.RevealPost(context.Background(), createdPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
require.Nil(t, revealedPost)
|
||||
CheckErrorID(t, err, "app.reveal_post.post_expired.app_error")
|
||||
}, "reveal expired post")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
_, client2 := createSecondUser(th.BasicChannel)
|
||||
post := createBurnOnReadPost(client2, th.BasicChannel)
|
||||
|
||||
user := th.BasicUser
|
||||
if client == th.SystemAdminClient {
|
||||
user = th.SystemAdminUser
|
||||
}
|
||||
|
||||
// Create expired read receipt
|
||||
readReceipt, err := th.App.Srv().Store().ReadReceipt().Save(th.Context, &model.ReadReceipt{
|
||||
PostID: post.Id,
|
||||
UserID: user.Id,
|
||||
ExpireAt: model.GetMillis() - 1000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, readReceipt)
|
||||
|
||||
revealedPost, resp, err := client.RevealPost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
require.Nil(t, revealedPost)
|
||||
CheckErrorID(t, err, "app.post.get.app_error")
|
||||
}, "reveal post with expired read receipt")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
_, client2 := createSecondUser(nil)
|
||||
|
||||
privateChannel, resp, err := client2.CreateChannel(context.Background(), &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Type: model.ChannelTypePrivate,
|
||||
Name: GenerateTestChannelName(),
|
||||
DisplayName: "Private Channel",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: privateChannel.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
|
||||
|
||||
createdPost, resp, err := client2.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
revealedPost, resp, err := client.RevealPost(context.Background(), createdPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
require.Nil(t, revealedPost)
|
||||
}, "user without channel access")
|
||||
}
|
||||
|
||||
func TestCreateBurnOnReadPost(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
|
||||
})
|
||||
|
||||
th := SetupEnterprise(t).InitBasic(t)
|
||||
|
||||
th.LinkUserToTeam(t, th.SystemAdminUser, th.BasicTeam)
|
||||
th.AddUserToChannel(t, th.SystemAdminUser, th.BasicChannel)
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
|
||||
createdPost, resp, err := client.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
require.NotNil(t, createdPost)
|
||||
require.Equal(t, model.PostTypeBurnOnRead, createdPost.Type)
|
||||
}, "create burn on read post")
|
||||
|
||||
t.Run("reveal burn on read post and verify in channel posts", func(t *testing.T) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
// Create burn-on-read post with basic user
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
|
||||
|
||||
createdPost, resp, err := th.Client.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
require.NotNil(t, createdPost)
|
||||
|
||||
// Create websocket client for system admin to receive reveal event
|
||||
wsClient := th.CreateConnectedWebSocketClientWithClient(t, th.SystemAdminClient)
|
||||
|
||||
// Get posts for channel with system admin client - verify post is not revealed by default
|
||||
posts, resp, err := th.SystemAdminClient.GetPostsForChannel(context.Background(), th.BasicChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, posts)
|
||||
require.NotNil(t, posts.Posts[createdPost.Id])
|
||||
unrevealedPost := posts.Posts[createdPost.Id]
|
||||
require.Equal(t, "", unrevealedPost.Message)
|
||||
// Check if the metadata is empty
|
||||
require.Equal(t, model.PostMetadata{}, *unrevealedPost.Metadata)
|
||||
|
||||
// Reveal the post with system admin client
|
||||
revealedPost, resp, err := th.SystemAdminClient.RevealPost(context.Background(), createdPost.Id)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, revealedPost)
|
||||
require.Equal(t, "burn on read message", revealedPost.Message)
|
||||
require.NotNil(t, revealedPost.Metadata)
|
||||
require.NotZero(t, revealedPost.Metadata.ExpireAt)
|
||||
|
||||
// Verify websocket client receives the reveal event
|
||||
var eventPost model.Post
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case event := <-wsClient.EventChannel:
|
||||
if event.EventType() == model.WebsocketEventPostRevealed {
|
||||
eventPostJSON, ok := event.GetData()["post"].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
err = json.Unmarshal([]byte(eventPostJSON), &eventPost)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return eventPost.Id == createdPost.Id
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond, "should have received post_revealed websocket event")
|
||||
require.Equal(t, createdPost.Id, eventPost.Id)
|
||||
require.Equal(t, "burn on read message", eventPost.Message)
|
||||
require.NotNil(t, eventPost.Metadata)
|
||||
require.NotZero(t, eventPost.Metadata.ExpireAt)
|
||||
|
||||
// Get the single post - verify it's revealed
|
||||
singlePost, resp, err := th.SystemAdminClient.GetPost(context.Background(), createdPost.Id, "")
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, singlePost)
|
||||
require.Equal(t, "burn on read message", singlePost.Message)
|
||||
require.NotNil(t, singlePost.Metadata)
|
||||
require.NotZero(t, singlePost.Metadata.ExpireAt)
|
||||
|
||||
// Query for posts in channel again - verify this time it's revealed
|
||||
postsAfterReveal, resp, err := th.SystemAdminClient.GetPostsForChannel(context.Background(), th.BasicChannel.Id, 0, 100, "", true, false)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, postsAfterReveal)
|
||||
require.NotNil(t, postsAfterReveal.Posts[createdPost.Id])
|
||||
revealedPostInChannel := postsAfterReveal.Posts[createdPost.Id]
|
||||
require.Equal(t, "burn on read message", revealedPostInChannel.Message)
|
||||
require.NotNil(t, revealedPostInChannel.Metadata)
|
||||
require.NotZero(t, revealedPostInChannel.Metadata.ExpireAt)
|
||||
})
|
||||
|
||||
t.Run("Create post send back pending post ID for post creator", func(t *testing.T) {
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
PendingPostId: model.NewId(),
|
||||
}
|
||||
|
||||
createdPost, response, err := th.Client.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, response)
|
||||
require.NotNil(t, createdPost)
|
||||
require.Equal(t, post.PendingPostId, createdPost.PendingPostId)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBurnPost(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
|
||||
})
|
||||
|
||||
th := SetupEnterprise(t).InitBasic(t)
|
||||
|
||||
th.LinkUserToTeam(t, th.SystemAdminUser, th.BasicTeam)
|
||||
th.AddUserToChannel(t, th.SystemAdminUser, th.BasicChannel)
|
||||
|
||||
// Helper to create burn-on-read post
|
||||
createBurnOnReadPost := func(client *model.Client4, channel *model.Channel) *model.Post {
|
||||
post := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
|
||||
|
||||
createdPost, resp, err := client.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
require.NotNil(t, createdPost)
|
||||
return createdPost
|
||||
}
|
||||
|
||||
// Helper to create and login second user
|
||||
createSecondUser := func(channel *model.Channel) (*model.User, *model.Client4) {
|
||||
user2 := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, user2, th.BasicTeam)
|
||||
if channel != nil {
|
||||
th.AddUserToChannel(t, user2, channel)
|
||||
}
|
||||
client2 := th.CreateClient()
|
||||
_, _, err := client2.Login(context.Background(), user2.Email, user2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, err = client2.Logout(context.Background())
|
||||
require.NoError(t, err)
|
||||
})
|
||||
return user2, client2
|
||||
}
|
||||
|
||||
t.Run("feature not enabled, burn post allowed", func(t *testing.T) {
|
||||
enableBurnOnReadFeature(th)
|
||||
post := createBurnOnReadPost(th.SystemAdminClient, th.BasicChannel)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.ServiceSettings.EnableBurnOnRead = model.NewPointer(false)
|
||||
})
|
||||
|
||||
_, resp, err := th.Client.RevealPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
resp, err = th.Client.BurnPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
})
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
regularPost := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "regular message",
|
||||
}
|
||||
createdPost, resp, err := th.Client.CreatePost(context.Background(), regularPost)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
resp, err = client.BurnPost(context.Background(), createdPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
CheckErrorID(t, err, "app.burn_post.not_burn_on_read.app_error")
|
||||
}, "burn regular post")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
resp, err := client.BurnPost(context.Background(), model.NewId())
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
}, "burn non-existing post")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
post := createBurnOnReadPost(client, th.BasicChannel)
|
||||
|
||||
resp, err := client.BurnPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
// Verify post is permanently deleted
|
||||
_, resp, err = client.GetPost(context.Background(), post.Id, "")
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
}, "author burns own post - permanently deleted")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
_, client2 := createSecondUser(th.BasicChannel)
|
||||
|
||||
post := createBurnOnReadPost(client2, th.BasicChannel)
|
||||
|
||||
resp, err := client.BurnPost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
CheckErrorID(t, err, "app.burn_post.not_revealed.app_error")
|
||||
}, "non-author burns post without read receipt")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
_, client2 := createSecondUser(th.BasicChannel)
|
||||
post := createBurnOnReadPost(client2, th.BasicChannel)
|
||||
|
||||
// Create websocket client to receive burn event
|
||||
wsClient := th.CreateConnectedWebSocketClientWithClient(t, client)
|
||||
|
||||
// Create expired read receipt
|
||||
revealedPost, resp, err := client.RevealPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, revealedPost)
|
||||
|
||||
resp, err = client.BurnPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
userID := th.BasicUser.Id
|
||||
if client == th.SystemAdminClient {
|
||||
userID = th.SystemAdminUser.Id
|
||||
}
|
||||
|
||||
// Verify receipt ExpireAt is unchanged (no-op)
|
||||
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, post.Id, userID)
|
||||
require.NoError(t, err)
|
||||
require.LessOrEqual(t, receipt.ExpireAt, revealedPost.Metadata.ExpireAt)
|
||||
|
||||
// Verify websocket client receives the burn event
|
||||
var eventPostID string
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case event := <-wsClient.EventChannel:
|
||||
if event.EventType() == model.WebsocketEventPostBurned {
|
||||
var ok bool
|
||||
eventPostID, ok = event.GetData()["post_id"].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return eventPostID == post.Id
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 100*time.Millisecond, "should have received post_burned websocket event")
|
||||
require.Equal(t, post.Id, eventPostID)
|
||||
}, "non-author burns post with expired read receipt")
|
||||
|
||||
th.TestForRegularAndSystemAdminClients(t, func(t *testing.T, client *model.Client4) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
_, client2 := createSecondUser(nil)
|
||||
|
||||
privateChannel, resp, err := client2.CreateChannel(context.Background(), &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Type: model.ChannelTypePrivate,
|
||||
Name: GenerateTestChannelName(),
|
||||
DisplayName: "Private Channel",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: privateChannel.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
|
||||
|
||||
createdPost, resp, err := client2.CreatePost(context.Background(), post)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
|
||||
resp, err = client.BurnPost(context.Background(), createdPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
}, "user without channel access")
|
||||
|
||||
t.Run("unauthorized access", func(t *testing.T) {
|
||||
enableBurnOnReadFeature(th)
|
||||
|
||||
post := createBurnOnReadPost(th.Client, th.BasicChannel)
|
||||
|
||||
// Create unauthenticated client
|
||||
unauthClient := th.CreateClient()
|
||||
resp, err := unauthClient.BurnPost(context.Background(), post.Id)
|
||||
require.Error(t, err)
|
||||
CheckUnauthorizedStatus(t, resp)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,3 +41,31 @@ func postPriorityCheckWithContext(where string, c *Context, priority *model.Post
|
|||
c.Err = appErr
|
||||
}
|
||||
}
|
||||
|
||||
// checkUploadFilePermissionForNewFiles checks upload_file permission only when
|
||||
// adding new files to a post, preventing permission bypass via cross-channel file attachments.
|
||||
func checkUploadFilePermissionForNewFiles(c *Context, newFileIds []string, originalPost *model.Post) {
|
||||
if len(newFileIds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
originalFileIDsMap := make(map[string]bool, len(originalPost.FileIds))
|
||||
for _, fileID := range originalPost.FileIds {
|
||||
originalFileIDsMap[fileID] = true
|
||||
}
|
||||
|
||||
hasNewFiles := false
|
||||
for _, fileID := range newFileIds {
|
||||
if !originalFileIDsMap[fileID] {
|
||||
hasNewFiles = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasNewFiles {
|
||||
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionUploadFile) {
|
||||
c.SetPermissionError(model.PermissionUploadFile)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,13 @@ func createSchedulePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "scheduledPost", &scheduledPost)
|
||||
|
||||
if len(scheduledPost.FileIds) > 0 {
|
||||
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), scheduledPost.ChannelId, model.PermissionUploadFile) {
|
||||
c.SetPermissionError(model.PermissionUploadFile)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
scheduledPostChecks("Api4.createSchedulePost", c, &scheduledPost)
|
||||
if c.Err != nil {
|
||||
return
|
||||
|
|
@ -169,12 +176,38 @@ func updateScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "scheduledPost", &scheduledPost)
|
||||
|
||||
userId := c.AppContext.Session().UserId
|
||||
existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPost.Id)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
if existingScheduledPost == nil {
|
||||
c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.existing_scheduled_post.not_exist", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if existingScheduledPost.UserId != userId {
|
||||
c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.update_permission.error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if len(scheduledPost.FileIds) > 0 {
|
||||
originalPost, err := existingScheduledPost.ToPost()
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.convert_to_post.error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
checkUploadFilePermissionForNewFiles(c, scheduledPost.FileIds, originalPost)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
scheduledPostChecks("Api4.updateScheduledPost", c, &scheduledPost)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.AppContext.Session().UserId
|
||||
updatedScheduledPost, appErr := c.App.UpdateScheduledPost(c.AppContext, userId, &scheduledPost, connectionID)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
|
|
@ -209,6 +242,21 @@ func deleteScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
model.AddEventParameterToAuditRec(auditRec, "scheduledPostId", scheduledPostId)
|
||||
|
||||
userId := c.AppContext.Session().UserId
|
||||
|
||||
existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPostId)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
if existingScheduledPost == nil {
|
||||
c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.existing_scheduled_post.not_exist", nil, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if existingScheduledPost.UserId != userId {
|
||||
c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.delete_permission.error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
connectionID := r.Header.Get(model.ConnectionId)
|
||||
deletedScheduledPost, appErr := c.App.DeleteScheduledPost(c.AppContext, userId, scheduledPostId, connectionID)
|
||||
if appErr != nil {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,88 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateScheduledPost(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
||||
|
||||
t.Run("should not allow updating a scheduled post not belonging to the user", func(t *testing.T) {
|
||||
scheduledPost := &model.ScheduledPost{
|
||||
Draft: model.Draft{
|
||||
CreateAt: model.GetMillis(),
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "this is a scheduled post",
|
||||
},
|
||||
ScheduledAt: model.GetMillis() + 100000,
|
||||
}
|
||||
createdScheduledPost, _, err := th.Client.CreateScheduledPost(context.Background(), scheduledPost)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdScheduledPost)
|
||||
|
||||
originalMessage := createdScheduledPost.Message
|
||||
originalScheduledAt := createdScheduledPost.ScheduledAt
|
||||
|
||||
createdScheduledPost.ScheduledAt = model.GetMillis() + 9999999
|
||||
createdScheduledPost.Message = "Updated Message!!!"
|
||||
|
||||
// Switch to BasicUser2
|
||||
th.LoginBasic2(t)
|
||||
|
||||
_, resp, err := th.Client.UpdateScheduledPost(context.Background(), createdScheduledPost)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
// Switch back to original user and verify the post wasn't modified
|
||||
th.LoginBasic(t)
|
||||
|
||||
fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fetchedPost)
|
||||
require.Equal(t, originalMessage, fetchedPost.Message)
|
||||
require.Equal(t, originalScheduledAt, fetchedPost.ScheduledAt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteScheduledPost(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
||||
|
||||
t.Run("should not allow deleting a scheduled post not belonging to the user", func(t *testing.T) {
|
||||
scheduledPost := &model.ScheduledPost{
|
||||
Draft: model.Draft{
|
||||
CreateAt: model.GetMillis(),
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "this is a scheduled post",
|
||||
},
|
||||
ScheduledAt: model.GetMillis() + 100000,
|
||||
}
|
||||
createdScheduledPost, _, err := th.Client.CreateScheduledPost(context.Background(), scheduledPost)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdScheduledPost)
|
||||
|
||||
// Switch to BasicUser2
|
||||
th.LoginBasic2(t)
|
||||
|
||||
_, resp, err := th.Client.DeleteScheduledPost(context.Background(), createdScheduledPost.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
// Switch back to original user and verify the post wasn't deleted
|
||||
th.LoginBasic(t)
|
||||
|
||||
fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fetchedPost)
|
||||
require.Equal(t, createdScheduledPost.Id, fetchedPost.Id)
|
||||
require.Equal(t, createdScheduledPost.Message, fetchedPost.Message)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateScheduledPost(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
|
|
|||
|
|
@ -127,6 +127,12 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Don't sanitize the team here since the user will be a team admin and their session won't reflect that yet
|
||||
// instead check the scheme roles for the team and if the user has the permission to invite users
|
||||
_, schemeUserRole, schemeAdminRole, schemeErr := c.App.GetSchemeRolesForTeam(rteam.Id)
|
||||
if schemeErr != nil || !c.App.RolesGrantPermission([]string{schemeUserRole, schemeAdminRole}, model.PermissionInviteUser.Id) {
|
||||
// If we can't check permissions, fail secure by hiding the invite_id because the team is already created above
|
||||
rteam.InviteId = ""
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(&team)
|
||||
|
|
@ -263,6 +269,20 @@ func updateTeam(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
oldTeam, err := c.App.GetTeam(c.Params.TeamId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
// Updating AllowOpenInvite or AllowedDomains requires InviteUser permission
|
||||
if (team.AllowOpenInvite != oldTeam.AllowOpenInvite || team.AllowedDomains != oldTeam.AllowedDomains) && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionInviteUser) {
|
||||
c.SetPermissionError(model.PermissionInviteUser)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.AddEventPriorState(oldTeam)
|
||||
|
||||
updatedTeam, err := c.App.UpdateTeam(&team)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
|
|
|
|||
|
|
@ -233,6 +233,29 @@ func TestCreateTeamSanitization(t *testing.T) {
|
|||
}, "system admin")
|
||||
}
|
||||
|
||||
func TestCreateTeamInviteIdHiddenWithoutInvitePermission(t *testing.T) {
|
||||
th := Setup(t)
|
||||
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
|
||||
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
|
||||
|
||||
// Remove PermissionInviteUser from the default team user role
|
||||
th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
|
||||
// Regular user creates a team - InviteId should be hidden
|
||||
// since the team user role lacks invite permission
|
||||
rteam, _, err := th.Client.CreateTeam(context.Background(), &model.Team{
|
||||
DisplayName: "Team Without Invite Permission",
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
AllowedDomains: "simulator.amazonses.com,localhost",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, rteam.Email, "should not have sanitized email")
|
||||
require.Empty(t, rteam.InviteId, "should have hidden invite_id when user lacks invite permission")
|
||||
}
|
||||
|
||||
func TestGetTeam(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
|
|
@ -543,6 +566,114 @@ func TestUpdateTeam(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestUpdateTeamInviteUserPermission(t *testing.T) {
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// Create a team with AllowOpenInvite=false
|
||||
team := &model.Team{
|
||||
DisplayName: "Test Team",
|
||||
Name: GenerateTestTeamName(),
|
||||
Email: th.GenerateTestEmail(),
|
||||
Type: model.TeamOpen,
|
||||
AllowOpenInvite: false,
|
||||
}
|
||||
team, _, err := th.Client.CreateTeam(context.Background(), team)
|
||||
require.NoError(t, err)
|
||||
|
||||
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
|
||||
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
|
||||
|
||||
t.Run("user with InviteUser permission can change AllowOpenInvite", func(t *testing.T) {
|
||||
th.AddPermissionToRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
|
||||
team.AllowOpenInvite = true
|
||||
var updatedTeam *model.Team
|
||||
updatedTeam, _, err = th.Client.UpdateTeam(context.Background(), team)
|
||||
require.NoError(t, err)
|
||||
require.True(t, updatedTeam.AllowOpenInvite)
|
||||
|
||||
// Reset for next test
|
||||
team.AllowOpenInvite = false
|
||||
updatedTeam, _, err = th.Client.UpdateTeam(context.Background(), team)
|
||||
require.NoError(t, err)
|
||||
require.False(t, updatedTeam.AllowOpenInvite)
|
||||
})
|
||||
|
||||
t.Run("user without InviteUser permission cannot change AllowOpenInvite", func(t *testing.T) {
|
||||
// Remove InviteUser permission from team user and admin roles
|
||||
th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
|
||||
|
||||
// Attempt to change AllowOpenInvite to true
|
||||
team.AllowOpenInvite = true
|
||||
var resp *model.Response
|
||||
_, resp, err = th.Client.UpdateTeam(context.Background(), team)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
// Verify the team's AllowOpenInvite didn't change
|
||||
var fetchedTeam *model.Team
|
||||
fetchedTeam, _, err = th.SystemAdminClient.GetTeam(context.Background(), team.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.False(t, fetchedTeam.AllowOpenInvite, "AllowOpenInvite should still be false")
|
||||
})
|
||||
|
||||
t.Run("user without InviteUser permission cannot change AllowedDomains", func(t *testing.T) {
|
||||
// Remove InviteUser permission from team user and admin roles
|
||||
th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
|
||||
|
||||
// Attempt to change AllowedDomains
|
||||
team.AllowedDomains = "example.com"
|
||||
var resp *model.Response
|
||||
_, resp, err = th.Client.UpdateTeam(context.Background(), team)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
// Verify the team's AllowedDomains didn't change
|
||||
var fetchedTeam *model.Team
|
||||
fetchedTeam, _, err = th.SystemAdminClient.GetTeam(context.Background(), team.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, fetchedTeam.AllowedDomains, "AllowedDomains should still be empty")
|
||||
})
|
||||
|
||||
t.Run("user without InviteUser permission can change other fields", func(t *testing.T) {
|
||||
// Remove InviteUser permission
|
||||
th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
|
||||
|
||||
// Refetch the team to get clean state
|
||||
team, _, err = th.SystemAdminClient.GetTeam(context.Background(), team.Id, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Change DisplayName and Description (should succeed)
|
||||
team.DisplayName = "Updated Display Name"
|
||||
team.Description = "Updated Description"
|
||||
var updatedTeam *model.Team
|
||||
updatedTeam, _, err = th.Client.UpdateTeam(context.Background(), team)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Updated Display Name", updatedTeam.DisplayName)
|
||||
require.Equal(t, "Updated Description", updatedTeam.Description)
|
||||
})
|
||||
|
||||
t.Run("system admin can change AllowOpenInvite regardless of permissions", func(t *testing.T) {
|
||||
// Remove InviteUser permission for regular roles
|
||||
th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
|
||||
th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
|
||||
|
||||
// Refetch the team
|
||||
team, _, err = th.SystemAdminClient.GetTeam(context.Background(), team.Id, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// System admin should be able to change AllowOpenInvite
|
||||
team.AllowOpenInvite = true
|
||||
var updatedTeam *model.Team
|
||||
updatedTeam, _, err = th.SystemAdminClient.UpdateTeam(context.Background(), team)
|
||||
require.NoError(t, err)
|
||||
require.True(t, updatedTeam.AllowOpenInvite)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateTeamPrivacyInvitePermissions(t *testing.T) {
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
|
|
|||
|
|
@ -2311,7 +2311,7 @@ func getLoginType(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||
c.Logger.Debug("Guest magic link email sent successfully", mlog.String("user_id", user.Id))
|
||||
|
||||
if jErr := json.NewEncoder(w).Encode(model.LoginTypeResponse{
|
||||
AuthService: "guest_magic_link",
|
||||
AuthService: model.UserAuthServiceMagicLink,
|
||||
}); jErr != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2884,6 +2884,7 @@ func (a *App) MarkChannelAsUnreadFromPost(rctx request.CTX, postID string, userI
|
|||
if !collapsedThreadsSupported || !a.IsCRTEnabledForUser(rctx, userID) {
|
||||
return a.markChannelAsUnreadFromPostCRTUnsupported(rctx, postID, userID)
|
||||
}
|
||||
|
||||
post, err := a.GetSinglePost(rctx, postID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -3688,7 +3689,25 @@ func (a *App) getDirectChannel(rctx request.CTX, userID, otherUserID string) (*m
|
|||
return a.Srv().getDirectChannel(rctx, userID, otherUserID)
|
||||
}
|
||||
|
||||
// GetDirectOrGroupMessageMembersCommonTeamsAsUser is a variant of GetDirectOrGroupMessageMembersCommonTeams
|
||||
// that returns results relative to the requesting user from the session in the request context.
|
||||
func (a *App) GetDirectOrGroupMessageMembersCommonTeamsAsUser(rctx request.CTX, channelID string) ([]*model.Team, *model.AppError) {
|
||||
return a.getDirectOrGroupMessageMembersCommonTeams(rctx, rctx.Session().UserId, channelID)
|
||||
}
|
||||
|
||||
// GetDirectOrGroupMessageMembersCommonTeams returns the set of teams in common for the members of the given DM/GM channel.
|
||||
//
|
||||
// Prefer GetDirectOrGroupMessageMembersCommonTeamsAsUser unless the request context is independent of any given user.
|
||||
func (a *App) GetDirectOrGroupMessageMembersCommonTeams(rctx request.CTX, channelID string) ([]*model.Team, *model.AppError) {
|
||||
return a.getDirectOrGroupMessageMembersCommonTeams(rctx, "", channelID)
|
||||
}
|
||||
|
||||
// getDirectOrGroupMessageMembersCommonTeams returns the set teams common to the members of the given channel.
|
||||
//
|
||||
// If a requesting user id is specified, but the user isn't an active member of the channel, we return an empty
|
||||
// set of channels. We don't just exclude all inactive users to offer more flexibility to the remaining users
|
||||
// on where to create the replacement channel.
|
||||
func (a *App) getDirectOrGroupMessageMembersCommonTeams(rctx request.CTX, requestingUserID, channelID string) ([]*model.Team, *model.AppError) {
|
||||
channel, appErr := a.GetChannel(rctx, channelID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
|
|
@ -3705,6 +3724,9 @@ func (a *App) GetDirectOrGroupMessageMembersCommonTeams(rctx request.CTX, channe
|
|||
Inactive: false,
|
||||
Active: true,
|
||||
})
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
userIDs := make([]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
|
|
@ -3720,6 +3742,13 @@ func (a *App) GetDirectOrGroupMessageMembersCommonTeams(rctx request.CTX, channe
|
|||
userIDs = append(userIDs, user.Id)
|
||||
}
|
||||
|
||||
// If a requesting user is specified, but we don't find them above as an active member
|
||||
// of the channel, just short-circuit and return an empty set. We don't return an error
|
||||
// as this is a valid result for some callers.
|
||||
if requestingUserID != "" && !slices.Contains(userIDs, requestingUserID) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
commonTeamIDs, err := a.Srv().Store().Team().GetCommonTeamIDsForMultipleUsers(userIDs)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("GetDirectOrGroupMessageMembersCommonTeams", "app.channel.get_common_teams.store_get_common_teams_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -713,7 +714,7 @@ func TestAddUserToChannelCreatesChannelMemberHistoryRecord(t *testing.T) {
|
|||
assert.Equal(t, channel.Id, history.ChannelId)
|
||||
channelMemberHistoryUserIds = append(channelMemberHistoryUserIds, history.UserId)
|
||||
}
|
||||
assert.Equal(t, groupUserIds, channelMemberHistoryUserIds)
|
||||
assert.ElementsMatch(t, groupUserIds, channelMemberHistoryUserIds)
|
||||
}
|
||||
|
||||
func TestUsersAndPostsCreateActivityInChannel(t *testing.T) {
|
||||
|
|
@ -2829,57 +2830,222 @@ func TestGetDirectOrGroupMessageMembersCommonTeams(t *testing.T) {
|
|||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
teamsToCreate := 2
|
||||
usersToCreate := 4 // at least 3 users to create a GM channel, last user is not in any team
|
||||
teams := make([]string, 0, teamsToCreate)
|
||||
for i := 0; i < cap(teams); i++ {
|
||||
team := th.CreateTeam(t)
|
||||
defer func(team *model.Team) {
|
||||
appErr := th.App.PermanentDeleteTeam(th.Context, team)
|
||||
require.Nil(t, appErr)
|
||||
}(team)
|
||||
teams = append(teams, team.Id)
|
||||
team1 := th.CreateTeam(t)
|
||||
team2 := th.CreateTeam(t)
|
||||
|
||||
user1 := th.CreateUser(t)
|
||||
user2 := th.CreateUser(t)
|
||||
user3 := th.CreateUser(t)
|
||||
user4NotInAnyTeams := th.CreateUser(t)
|
||||
unrelatedUser := th.CreateUser(t)
|
||||
|
||||
// All of user1, user2 and user3, and unrelatedUser on team1
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
th.LinkUserToTeam(t, user3, team1)
|
||||
th.LinkUserToTeam(t, unrelatedUser, team1)
|
||||
|
||||
// Only user2, user3, and unrelatedUser on team2
|
||||
th.LinkUserToTeam(t, user2, team2)
|
||||
th.LinkUserToTeam(t, user3, team2)
|
||||
th.LinkUserToTeam(t, unrelatedUser, team2)
|
||||
|
||||
assertNoTeamsInCommon := func(t *testing.T, commonTeams []*model.Team) {
|
||||
t.Helper()
|
||||
assert.Empty(t, commonTeams, "expected no teams in common")
|
||||
}
|
||||
|
||||
users := make([]string, 0, usersToCreate)
|
||||
for i := 0; i < cap(users); i++ {
|
||||
user := th.CreateUser(t)
|
||||
defer func(user *model.User) {
|
||||
appErr := th.App.PermanentDeleteUser(th.Context, user)
|
||||
require.Nil(t, appErr)
|
||||
}(user)
|
||||
users = append(users, user.Id)
|
||||
}
|
||||
|
||||
for _, teamId := range teams {
|
||||
// add first 3 users to each team, last user is not in any team
|
||||
for i := range 3 {
|
||||
_, _, appErr := th.App.AddUserToTeam(th.Context, teamId, users[i], "")
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1InCommon := func(t *testing.T, commonTeams []*model.Team) {
|
||||
t.Helper()
|
||||
if assert.Len(t, commonTeams, 1, "expected 1 team in common") {
|
||||
assert.Equal(t, team1.Id, commonTeams[0].Id, "expected team1 in common")
|
||||
}
|
||||
}
|
||||
|
||||
// create GM channel with first 3 users who share common teams
|
||||
gmChannel, appErr := th.App.createGroupChannel(th.Context, users[:3], users[0])
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, gmChannel)
|
||||
assertTeam1And2InCommon := func(t *testing.T, commonTeams []*model.Team) {
|
||||
t.Helper()
|
||||
if assert.Len(t, commonTeams, 2, "expected 2 teams in common") {
|
||||
assert.True(t, slices.ContainsFunc(commonTeams, func(team *model.Team) bool {
|
||||
return team.Id == team1.Id
|
||||
}), "expected team1 in common")
|
||||
assert.True(t, slices.ContainsFunc(commonTeams, func(team *model.Team) bool {
|
||||
return team.Id == team2.Id
|
||||
}), "expected team2 in common")
|
||||
}
|
||||
}
|
||||
|
||||
// normally you can't create a GM channel with users that don't share any teams, but we do it here to test the edge case
|
||||
// create GM channel with last 3 users, where last member is not in any team
|
||||
otherGMChannel, appErr := th.App.createGroupChannel(th.Context, users[1:], users[0])
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, otherGMChannel)
|
||||
|
||||
t.Run("Get teams for GM channel", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeams(th.Context, gmChannel.Id)
|
||||
t.Run("teams for dm with user1 and user2", func(t *testing.T) {
|
||||
dmChannel, appErr := th.App.createDirectChannel(th.Context, user1.Id, user2.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, 2, len(commonTeams))
|
||||
require.NotNil(t, dmChannel)
|
||||
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeams(th.Context, dmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1InCommon(t, commonTeams)
|
||||
|
||||
t.Run("as user1", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user1.Id}), dmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1InCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as user2", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user2.Id}), dmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1InCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as unrelatedUser", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: unrelatedUser.Id}), dmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("No common teams", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeams(th.Context, otherGMChannel.Id)
|
||||
t.Run("teams for dm with user1 and deactivatedUser", func(t *testing.T) {
|
||||
deactivatedUser := th.CreateUser(t)
|
||||
|
||||
// deactiverUser on team1 only
|
||||
th.LinkUserToTeam(t, deactivatedUser, team1)
|
||||
|
||||
dmChannel, appErr := th.App.createDirectChannel(th.Context, user1.Id, deactivatedUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, 0, len(commonTeams))
|
||||
require.NotNil(t, dmChannel)
|
||||
|
||||
_, appErr = th.App.UpdateActive(th.Context, deactivatedUser, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeams(th.Context, dmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
// By default, we return the teams common only to active users.
|
||||
assertTeam1InCommon(t, commonTeams)
|
||||
|
||||
t.Run("as user1", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user1.Id}), dmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1InCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as deactivatedUser", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: deactivatedUser.Id}), dmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// When requesting as deactivated user in the dm, no teams are considered in common.
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as unrelatedUser", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: unrelatedUser.Id}), dmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("teams for gm with user1, user2 and user3", func(t *testing.T) {
|
||||
gmChannel, appErr := th.App.createGroupChannel(th.Context, []string{user1.Id, user2.Id, user3.Id}, user1.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, gmChannel)
|
||||
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeams(th.Context, gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1InCommon(t, commonTeams)
|
||||
|
||||
t.Run("as user1", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user1.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1InCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as user2", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user2.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1InCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as user3", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user3.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1InCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as unrelatedUser", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: unrelatedUser.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("teams for gm with user2, user3, and user4NotInAnyTeams", func(t *testing.T) {
|
||||
gmChannel, appErr := th.App.createGroupChannel(th.Context, []string{user2.Id, user3.Id, user4NotInAnyTeams.Id}, user1.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, gmChannel)
|
||||
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeams(th.Context, gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
|
||||
t.Run("as user2", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user2.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as user3", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user3.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as unrelatedUser", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: unrelatedUser.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("teams for gm with user2, user3, and deactivatedUser", func(t *testing.T) {
|
||||
deactivatedUser := th.CreateUser(t)
|
||||
|
||||
// deactiverUser on team2 only
|
||||
th.LinkUserToTeam(t, deactivatedUser, team2)
|
||||
|
||||
gmChannel, appErr := th.App.createGroupChannel(th.Context, []string{user2.Id, user3.Id, deactivatedUser.Id}, user1.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, gmChannel)
|
||||
|
||||
_, appErr = th.App.UpdateActive(th.Context, deactivatedUser, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeams(th.Context, gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
// By default, we return the teams common only to active users.
|
||||
assertTeam1And2InCommon(t, commonTeams)
|
||||
|
||||
t.Run("as user2", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user2.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1And2InCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as user3", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: user3.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertTeam1And2InCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as deactivatedUser", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: deactivatedUser.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// When requesting as deactivated user in the gm, no teams are considered in common.
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
})
|
||||
|
||||
t.Run("as unrelatedUser", func(t *testing.T) {
|
||||
commonTeams, appErr := th.App.GetDirectOrGroupMessageMembersCommonTeamsAsUser(th.Context.WithSession(&model.Session{UserId: unrelatedUser.Id}), gmChannel.Id)
|
||||
require.Nil(t, appErr)
|
||||
assertNoTeamsInCommon(t, commonTeams)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
|
|
@ -1480,7 +1483,69 @@ func (a *App) SearchFilesInTeamForUser(rctx request.CTX, terms string, userId st
|
|||
}
|
||||
}
|
||||
|
||||
return fileInfoSearchResults, a.filterInaccessibleFiles(fileInfoSearchResults, filterFileOptions{assumeSortedCreatedAt: true})
|
||||
if appErr := a.filterInaccessibleFiles(fileInfoSearchResults, filterFileOptions{assumeSortedCreatedAt: true}); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
if appErr := a.FilterFilesByChannelPermissions(rctx, fileInfoSearchResults, userId); appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
return fileInfoSearchResults, nil
|
||||
}
|
||||
|
||||
func (a *App) FilterFilesByChannelPermissions(rctx request.CTX, fileList *model.FileInfoList, userID string) *model.AppError {
|
||||
if fileList == nil || fileList.FileInfos == nil || len(fileList.FileInfos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
channels := make(map[string]*model.Channel)
|
||||
for _, fileInfo := range fileList.FileInfos {
|
||||
if fileInfo.ChannelId != "" {
|
||||
channels[fileInfo.ChannelId] = nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(channels) > 0 {
|
||||
channelIDs := slices.Collect(maps.Keys(channels))
|
||||
channelList, err := a.GetChannels(rctx, channelIDs)
|
||||
if err != nil && err.StatusCode != http.StatusNotFound {
|
||||
return err
|
||||
}
|
||||
for _, channel := range channelList {
|
||||
channels[channel.Id] = channel
|
||||
}
|
||||
}
|
||||
|
||||
channelReadPermission := make(map[string]bool)
|
||||
filteredFiles := make(map[string]*model.FileInfo)
|
||||
filteredOrder := []string{}
|
||||
|
||||
for _, fileID := range fileList.Order {
|
||||
fileInfo, ok := fileList.FileInfos[fileID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := channelReadPermission[fileInfo.ChannelId]; !ok {
|
||||
channel := channels[fileInfo.ChannelId]
|
||||
allowed := false
|
||||
if channel != nil {
|
||||
allowed = a.HasPermissionToReadChannel(rctx, userID, channel)
|
||||
}
|
||||
channelReadPermission[fileInfo.ChannelId] = allowed
|
||||
}
|
||||
|
||||
if channelReadPermission[fileInfo.ChannelId] {
|
||||
filteredFiles[fileID] = fileInfo
|
||||
filteredOrder = append(filteredOrder, fileID)
|
||||
}
|
||||
}
|
||||
|
||||
fileList.FileInfos = filteredFiles
|
||||
fileList.Order = filteredOrder
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ExtractContentFromFileInfo(rctx request.CTX, fileInfo *model.FileInfo) error {
|
||||
|
|
|
|||
|
|
@ -836,3 +836,141 @@ func TestPermanentDeleteFilesByPost(t *testing.T) {
|
|||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterFilesByChannelPermissions(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.GuestAccountsSettings.Enable = true
|
||||
})
|
||||
|
||||
guestUser := th.CreateGuest(t)
|
||||
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, guestUser.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, guestUser, privateChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, guestUser, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
post1 := th.CreatePost(t, th.BasicChannel)
|
||||
post2 := th.CreatePost(t, privateChannel)
|
||||
post3 := th.CreatePost(t, th.BasicChannel)
|
||||
|
||||
fileInfo1 := th.CreateFileInfo(t, th.BasicUser.Id, post1.Id, th.BasicChannel.Id)
|
||||
fileInfo2 := th.CreateFileInfo(t, th.BasicUser.Id, post2.Id, privateChannel.Id)
|
||||
fileInfo3 := th.CreateFileInfo(t, th.BasicUser.Id, post3.Id, th.BasicChannel.Id)
|
||||
|
||||
t.Run("should filter files when user has read_channel_content permission", func(t *testing.T) {
|
||||
fileList := model.NewFileInfoList()
|
||||
fileList.FileInfos[fileInfo1.Id] = fileInfo1
|
||||
fileList.FileInfos[fileInfo2.Id] = fileInfo2
|
||||
fileList.FileInfos[fileInfo3.Id] = fileInfo3
|
||||
fileList.Order = []string{fileInfo1.Id, fileInfo2.Id, fileInfo3.Id}
|
||||
|
||||
// BasicUser should have access to all files
|
||||
appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, fileList.FileInfos, 3)
|
||||
require.Len(t, fileList.Order, 3)
|
||||
})
|
||||
|
||||
t.Run("should filter files when guest has read_channel_content permission", func(t *testing.T) {
|
||||
fileList := model.NewFileInfoList()
|
||||
fileList.FileInfos[fileInfo1.Id] = fileInfo1
|
||||
fileList.FileInfos[fileInfo2.Id] = fileInfo2
|
||||
fileList.FileInfos[fileInfo3.Id] = fileInfo3
|
||||
fileList.Order = []string{fileInfo1.Id, fileInfo2.Id, fileInfo3.Id}
|
||||
|
||||
appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, guestUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, fileList.FileInfos, 3)
|
||||
require.Len(t, fileList.Order, 3)
|
||||
})
|
||||
|
||||
t.Run("should filter files when guest does not have read_channel_content permission", func(t *testing.T) {
|
||||
channelGuestRole, appErr := th.App.GetRoleByName(th.Context, model.ChannelGuestRoleId)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
originalPermissions := make([]string, len(channelGuestRole.Permissions))
|
||||
copy(originalPermissions, channelGuestRole.Permissions)
|
||||
|
||||
newPermissions := []string{}
|
||||
for _, perm := range channelGuestRole.Permissions {
|
||||
if perm != model.PermissionReadChannelContent.Id && perm != model.PermissionReadChannel.Id {
|
||||
newPermissions = append(newPermissions, perm)
|
||||
}
|
||||
}
|
||||
|
||||
_, appErr = th.App.PatchRole(channelGuestRole, &model.RolePatch{
|
||||
Permissions: &newPermissions,
|
||||
})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
defer func() {
|
||||
_, err := th.App.PatchRole(channelGuestRole, &model.RolePatch{
|
||||
Permissions: &originalPermissions,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
}()
|
||||
|
||||
fileList := model.NewFileInfoList()
|
||||
fileList.FileInfos[fileInfo1.Id] = fileInfo1
|
||||
fileList.FileInfos[fileInfo2.Id] = fileInfo2
|
||||
fileList.FileInfos[fileInfo3.Id] = fileInfo3
|
||||
fileList.Order = []string{fileInfo1.Id, fileInfo2.Id, fileInfo3.Id}
|
||||
|
||||
appErr = th.App.FilterFilesByChannelPermissions(th.Context, fileList, guestUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, fileList.FileInfos, 0)
|
||||
require.Len(t, fileList.Order, 0)
|
||||
})
|
||||
|
||||
t.Run("should handle empty file list", func(t *testing.T) {
|
||||
fileList := model.NewFileInfoList()
|
||||
appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, fileList.FileInfos, 0)
|
||||
require.Len(t, fileList.Order, 0)
|
||||
})
|
||||
|
||||
t.Run("should handle nil file list", func(t *testing.T) {
|
||||
appErr := th.App.FilterFilesByChannelPermissions(th.Context, nil, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
})
|
||||
|
||||
t.Run("should handle files with empty channel IDs", func(t *testing.T) {
|
||||
fileList := model.NewFileInfoList()
|
||||
fileWithoutChannel := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: "",
|
||||
Name: "test.txt",
|
||||
}
|
||||
fileList.FileInfos[fileWithoutChannel.Id] = fileWithoutChannel
|
||||
fileList.Order = []string{fileWithoutChannel.Id}
|
||||
|
||||
appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, fileList.FileInfos, 0)
|
||||
require.Len(t, fileList.Order, 0)
|
||||
})
|
||||
|
||||
t.Run("should handle files from non-existent channels", func(t *testing.T) {
|
||||
fileList := model.NewFileInfoList()
|
||||
fileWithInvalidChannel := &model.FileInfo{
|
||||
Id: model.NewId(),
|
||||
ChannelId: model.NewId(),
|
||||
Name: "test.txt",
|
||||
}
|
||||
fileList.FileInfos[fileWithInvalidChannel.Id] = fileWithInvalidChannel
|
||||
fileList.Order = []string{fileWithInvalidChannel.Id}
|
||||
|
||||
appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, fileList.FileInfos, 0)
|
||||
require.Len(t, fileList.Order, 0)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import (
|
|||
"io"
|
||||
"sync"
|
||||
|
||||
_ "github.com/oov/psd"
|
||||
_ "golang.org/x/image/bmp"
|
||||
_ "golang.org/x/image/tiff"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
|
|
|||
|
|
@ -115,6 +115,20 @@ func TestDecoderDecode(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestPSDNotSupported(t *testing.T) {
|
||||
// MM-67077: PSD preview support was removed due to memory vulnerability in oov/psd package
|
||||
d, err := NewDecoder(DecoderOptions{})
|
||||
require.NotNil(t, d)
|
||||
require.NoError(t, err)
|
||||
|
||||
// PSD file header magic bytes: "8BPS" followed by version (0x0001 for PSD)
|
||||
psdHeader := []byte("8BPS\x00\x01")
|
||||
_, _, err = d.Decode(bytes.NewReader(psdHeader))
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown format")
|
||||
}
|
||||
|
||||
func TestDecoderDecodeMemBounded(t *testing.T) {
|
||||
t.Run("concurrency bounded", func(t *testing.T) {
|
||||
d, err := NewDecoder(DecoderOptions{
|
||||
|
|
|
|||
|
|
@ -95,23 +95,25 @@ func (a *App) GetUserForLogin(rctx request.CTX, id, loginId string) (*model.User
|
|||
enableUsername := *a.Config().EmailSettings.EnableSignInWithUsername
|
||||
enableEmail := *a.Config().EmailSettings.EnableSignInWithEmail
|
||||
|
||||
// If we are given a userID then fail if we can't find a user with that ID
|
||||
if id != "" {
|
||||
user, err := a.GetUser(id)
|
||||
if err != nil {
|
||||
if err.Id != MissingAccountError {
|
||||
err.StatusCode = http.StatusInternalServerError
|
||||
if enableEmail || enableUsername {
|
||||
// If we are given a userID then fail if we can't find a user with that ID
|
||||
if id != "" {
|
||||
user, err := a.GetUser(id)
|
||||
if err != nil {
|
||||
if err.Id != MissingAccountError {
|
||||
err.StatusCode = http.StatusInternalServerError
|
||||
return nil, err
|
||||
}
|
||||
err.StatusCode = http.StatusBadRequest
|
||||
return nil, err
|
||||
}
|
||||
err.StatusCode = http.StatusBadRequest
|
||||
return nil, err
|
||||
return user, nil
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Try to get the user by username/email
|
||||
if user, err := a.Srv().Store().User().GetForLogin(loginId, enableUsername, enableEmail); err == nil {
|
||||
return user, nil
|
||||
// Try to get the user by username/email
|
||||
if user, err := a.Srv().Store().User().GetForLogin(loginId, enableUsername, enableEmail); err == nil {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get the user with LDAP if enabled
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
|
|
@ -52,3 +53,88 @@ func TestCWSLogin(t *testing.T) {
|
|||
require.Nil(t, user)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUserForLogin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
t.Run("Should get user with username when sign in with username is enabled", func(t *testing.T) {
|
||||
th.UpdateConfig(t, func(config *model.Config) {
|
||||
config.EmailSettings.EnableSignInWithUsername = model.NewPointer(true)
|
||||
})
|
||||
|
||||
user, appErr := th.App.GetUserForLogin(th.Context, "", th.BasicUser.Username)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, user)
|
||||
require.Equal(t, th.BasicUser.Username, user.Username)
|
||||
})
|
||||
|
||||
t.Run("Should not get user with username when sign in with username is disabled", func(t *testing.T) {
|
||||
th.UpdateConfig(t, func(config *model.Config) {
|
||||
config.EmailSettings.EnableSignInWithUsername = model.NewPointer(false)
|
||||
})
|
||||
|
||||
user, appErr := th.App.GetUserForLogin(th.Context, "", th.BasicUser.Username)
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
|
||||
require.Nil(t, user)
|
||||
})
|
||||
|
||||
t.Run("Should get user with email when sign in with email is enabled", func(t *testing.T) {
|
||||
th.UpdateConfig(t, func(config *model.Config) {
|
||||
config.EmailSettings.EnableSignInWithEmail = model.NewPointer(true)
|
||||
})
|
||||
|
||||
user, appErr := th.App.GetUserForLogin(th.Context, "", th.BasicUser.Email)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, user)
|
||||
require.Equal(t, th.BasicUser.Username, user.Username)
|
||||
})
|
||||
|
||||
t.Run("Should not user with email when sign in with email is disabled", func(t *testing.T) {
|
||||
th.UpdateConfig(t, func(config *model.Config) {
|
||||
config.EmailSettings.EnableSignInWithEmail = model.NewPointer(false)
|
||||
})
|
||||
|
||||
user, appErr := th.App.GetUserForLogin(th.Context, "", th.BasicUser.Email)
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
|
||||
require.Nil(t, user)
|
||||
})
|
||||
|
||||
t.Run("Should get user with user ID when sign in with email is enabled", func(t *testing.T) {
|
||||
th.UpdateConfig(t, func(config *model.Config) {
|
||||
config.EmailSettings.EnableSignInWithEmail = model.NewPointer(true)
|
||||
config.EmailSettings.EnableSignInWithUsername = model.NewPointer(false)
|
||||
})
|
||||
|
||||
user, appErr := th.App.GetUserForLogin(th.Context, th.BasicUser.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, user)
|
||||
require.Equal(t, th.BasicUser.Username, user.Username)
|
||||
})
|
||||
|
||||
t.Run("Should get user with user ID when sign in with username is enabled", func(t *testing.T) {
|
||||
th.UpdateConfig(t, func(config *model.Config) {
|
||||
config.EmailSettings.EnableSignInWithEmail = model.NewPointer(false)
|
||||
config.EmailSettings.EnableSignInWithUsername = model.NewPointer(true)
|
||||
})
|
||||
|
||||
user, appErr := th.App.GetUserForLogin(th.Context, th.BasicUser.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, user)
|
||||
require.Equal(t, th.BasicUser.Username, user.Username)
|
||||
})
|
||||
|
||||
t.Run("Should not get user with user ID when both sign in with email and username are disabled", func(t *testing.T) {
|
||||
th.UpdateConfig(t, func(config *model.Config) {
|
||||
config.EmailSettings.EnableSignInWithEmail = model.NewPointer(false)
|
||||
config.EmailSettings.EnableSignInWithUsername = model.NewPointer(false)
|
||||
})
|
||||
|
||||
user, appErr := th.App.GetUserForLogin(th.Context, th.BasicUser.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
|
||||
require.Nil(t, user)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,17 @@ func (a *App) SendNotifications(rctx request.CTX, post *model.Post, team *model.
|
|||
mlog.String("post_id", post.Id),
|
||||
)
|
||||
|
||||
mentions, keywords := a.getExplicitMentionsAndKeywords(rctx, post, channel, profileMap, groups, channelMemberNotifyPropsMap, parentPostList)
|
||||
var mentions *MentionResults
|
||||
var keywords MentionKeywords
|
||||
if post.Type == model.PostTypeBurnOnRead {
|
||||
borPost, appErr := a.getBurnOnReadPost(store.RequestContextWithMaster(rctx), post)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
mentions, keywords = a.getExplicitMentionsAndKeywords(rctx, borPost, channel, profileMap, groups, channelMemberNotifyPropsMap, parentPostList)
|
||||
} else {
|
||||
mentions, keywords = a.getExplicitMentionsAndKeywords(rctx, post, channel, profileMap, groups, channelMemberNotifyPropsMap, parentPostList)
|
||||
}
|
||||
|
||||
var allActivityPushUserIds []string
|
||||
if channel.Type != model.ChannelTypeDirect {
|
||||
|
|
|
|||
|
|
@ -1316,7 +1316,10 @@ func TestClearPushNotificationSync(t *testing.T) {
|
|||
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
|
||||
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
|
||||
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
|
||||
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: model.NewId()}, nil)
|
||||
|
||||
diagnosticID := model.NewId()
|
||||
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: diagnosticID}, nil)
|
||||
mockSystemStore.On("GetByNameWithContext", mock.Anything, model.SystemServerId).Return(&model.System{Name: model.SystemServerId, Value: diagnosticID}, nil)
|
||||
|
||||
mockSessionStore := mocks.SessionStore{}
|
||||
mockSessionStore.On("GetSessionsWithActiveDeviceIds", mock.AnythingOfType("string")).Return([]*model.Session{sess1, sess2}, nil)
|
||||
|
|
@ -1393,7 +1396,10 @@ func TestUpdateMobileAppBadgeSync(t *testing.T) {
|
|||
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
|
||||
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
|
||||
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
|
||||
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: model.NewId()}, nil)
|
||||
|
||||
diagnosticID := model.NewId()
|
||||
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: diagnosticID}, nil)
|
||||
mockSystemStore.On("GetByNameWithContext", mock.Anything, model.SystemServerId).Return(&model.System{Name: model.SystemServerId, Value: diagnosticID}, nil)
|
||||
|
||||
mockSessionStore := mocks.SessionStore{}
|
||||
mockSessionStore.On("GetSessionsWithActiveDeviceIds", mock.AnythingOfType("string")).Return([]*model.Session{sess1, sess2}, nil)
|
||||
|
|
@ -1466,7 +1472,10 @@ func TestSendAckToPushProxy(t *testing.T) {
|
|||
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
|
||||
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
|
||||
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
|
||||
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: model.NewId()}, nil)
|
||||
|
||||
diagnosticID := model.NewId()
|
||||
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: diagnosticID}, nil)
|
||||
mockSystemStore.On("GetByNameWithContext", mock.Anything, model.SystemServerId).Return(&model.System{Name: model.SystemServerId, Value: diagnosticID}, nil)
|
||||
|
||||
mockStore.On("User").Return(&mockUserStore)
|
||||
mockStore.On("Post").Return(&mockPostStore)
|
||||
|
|
@ -1711,7 +1720,10 @@ func BenchmarkPushNotificationThroughput(b *testing.B) {
|
|||
mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil)
|
||||
mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil)
|
||||
mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)
|
||||
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: model.NewId()}, nil)
|
||||
|
||||
diagnosticID := model.NewId()
|
||||
mockSystemStore.On("Get").Return(model.StringMap{model.SystemServerId: diagnosticID}, nil)
|
||||
mockSystemStore.On("GetByNameWithContext", mock.Anything, model.SystemServerId).Return(&model.System{Name: model.SystemServerId, Value: diagnosticID}, nil)
|
||||
|
||||
mockSessionStore := mocks.SessionStore{}
|
||||
mockPreferenceStore := mocks.PreferenceStore{}
|
||||
|
|
|
|||
|
|
@ -1193,6 +1193,10 @@ func (a *App) SwitchOAuthToEmail(rctx request.CTX, email, password, requesterId
|
|||
return "", err
|
||||
}
|
||||
|
||||
if user.IsMagicLinkEnabled() {
|
||||
return "", model.NewAppError("SwitchOAuthToEmail", "api.user.oauth_to_email.magic_link.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if user.Id != requesterId {
|
||||
return "", model.NewAppError("SwitchOAuthToEmail", "api.user.oauth_to_email.context.app_error", nil, "", http.StatusForbidden)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,10 @@ func (p PBKDF2) Hash(password string) (string, error) {
|
|||
// The provided [phcparser.PHC] is validated to double-check it was generated with
|
||||
// this hasher and parameters.
|
||||
func (p PBKDF2) CompareHashAndPassword(hash phcparser.PHC, password string) error {
|
||||
if len(password) > PasswordMaxLengthBytes {
|
||||
return ErrPasswordTooLong
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if !p.IsPHCValid(hash) {
|
||||
return fmt.Errorf("the stored password does not comply with the PBKDF2 parser's PHC serialization")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"crypto/pbkdf2"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -46,6 +47,9 @@ func TestPBKDF2Hash(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPBKDF2CompareHashAndPassword(t *testing.T) {
|
||||
passwordTooLong := make([]byte, PasswordMaxLengthBytes+1)
|
||||
rand.Read(passwordTooLong)
|
||||
|
||||
testCases := []struct {
|
||||
testName string
|
||||
storedPwd string
|
||||
|
|
@ -71,6 +75,12 @@ func TestPBKDF2CompareHashAndPassword(t *testing.T) {
|
|||
"another password",
|
||||
ErrMismatchedHashAndPassword,
|
||||
},
|
||||
{
|
||||
"password too long",
|
||||
"stored password",
|
||||
string(passwordTooLong),
|
||||
ErrPasswordTooLong,
|
||||
},
|
||||
}
|
||||
|
||||
hasher := DefaultPBKDF2()
|
||||
|
|
|
|||
|
|
@ -570,6 +570,10 @@ func (wc *WebConn) writePump() {
|
|||
|
||||
evt, evtOk := msg.(*model.WebSocketEvent)
|
||||
|
||||
if evtOk && evt.IsRejected() {
|
||||
continue
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
var err error
|
||||
if evtOk {
|
||||
|
|
|
|||
|
|
@ -216,6 +216,14 @@ func (ps *PlatformService) InvalidateCacheForChannelPosts(channelID string) {
|
|||
ps.Store.Post().InvalidateLastPostTimeCache(channelID)
|
||||
}
|
||||
|
||||
func (ps *PlatformService) InvalidateCacheForReadReceipts(postID string) {
|
||||
ps.Store.ReadReceipt().InvalidateReadReceiptForPostsCache(postID)
|
||||
}
|
||||
|
||||
func (ps *PlatformService) InvalidateCacheForTemporaryPost(id string) {
|
||||
ps.Store.TemporaryPost().InvalidateTemporaryPost(id)
|
||||
}
|
||||
|
||||
func (ps *PlatformService) InvalidateCacheForUser(userID string) {
|
||||
ps.InvalidateChannelCacheForUser(userID)
|
||||
ps.Store.User().InvalidateProfileCacheForUser(userID)
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ func (api *PluginAPI) GetPluginConfig() map[string]any {
|
|||
}
|
||||
|
||||
func (api *PluginAPI) SavePluginConfig(pluginConfig map[string]any) *model.AppError {
|
||||
cfg := api.app.GetSanitizedConfig()
|
||||
cfg := api.app.Config().Clone()
|
||||
cfg.PluginSettings.Plugins[api.manifest.Id] = pluginConfig
|
||||
_, _, err := api.app.SaveConfig(cfg, true)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -880,6 +880,38 @@ func TestPluginAPISavePluginConfig(t *testing.T) {
|
|||
assert.Equal(t, expectedConfiguration, savedConfiguration)
|
||||
}
|
||||
|
||||
func TestPluginAPISavePluginConfigPreservesOtherPlugins(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
|
||||
otherPluginConfig := map[string]any{
|
||||
"setting1": "value1",
|
||||
"setting2": "value2",
|
||||
}
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.PluginSettings.Plugins["otherplugin"] = otherPluginConfig
|
||||
})
|
||||
|
||||
manifest := &model.Manifest{
|
||||
Id: "pluginid",
|
||||
SettingsSchema: &model.PluginSettingsSchema{
|
||||
Settings: []*model.PluginSetting{
|
||||
{Key: "MyStringSetting", Type: "text"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
api := NewPluginAPI(th.App, th.Context, manifest)
|
||||
|
||||
pluginConfig := map[string]any{"mystringsetting": "str"}
|
||||
appErr := api.SavePluginConfig(pluginConfig)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
cfg := th.App.Config()
|
||||
assert.Contains(t, cfg.PluginSettings.Plugins, "otherplugin")
|
||||
assert.Equal(t, otherPluginConfig, cfg.PluginSettings.Plugins["otherplugin"])
|
||||
}
|
||||
|
||||
func TestPluginAPILoadPluginConfiguration(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ import (
|
|||
"sort"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
)
|
||||
|
||||
type filterPostOptions struct {
|
||||
|
|
@ -263,3 +264,145 @@ func (a *App) getFilteredAccessiblePosts(posts []*model.Post, options filterPost
|
|||
filteredPosts, firstInaccessiblePostTime := linearFilterPostsSlice(posts, lastAccessiblePostTime)
|
||||
return filteredPosts, firstInaccessiblePostTime, nil
|
||||
}
|
||||
|
||||
// filterBurnOnReadPosts filters out burn-on-read posts from a PostList.
|
||||
// This should be used for contexts where burn-on-read posts should not appear (e.g., search results).
|
||||
func (a *App) filterBurnOnReadPosts(postList *model.PostList) *model.AppError {
|
||||
if postList == nil || postList.Posts == nil || len(postList.Posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if burn-on-read feature is enabled
|
||||
if !a.Config().FeatureFlags.BurnOnRead || !model.SafeDereference(a.Config().ServiceSettings.EnableBurnOnRead) {
|
||||
// Feature is not enabled, no need to filter
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect burn-on-read post IDs
|
||||
var burnOnReadPostIDs []string
|
||||
for postID, post := range postList.Posts {
|
||||
if post.Type == model.PostTypeBurnOnRead {
|
||||
burnOnReadPostIDs = append(burnOnReadPostIDs, postID)
|
||||
}
|
||||
}
|
||||
|
||||
// If no burn-on-read posts found, nothing to filter
|
||||
if len(burnOnReadPostIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove burn-on-read posts from the list
|
||||
for _, postID := range burnOnReadPostIDs {
|
||||
a.removePostFromList(postList, postID)
|
||||
}
|
||||
|
||||
// Filter Order slice directly to ensure all burn-on-read posts are removed
|
||||
filteredOrder := make([]string, 0, len(postList.Order))
|
||||
for _, postID := range postList.Order {
|
||||
if post, exists := postList.Posts[postID]; exists && post.Type != model.PostTypeBurnOnRead {
|
||||
filteredOrder = append(filteredOrder, postID)
|
||||
}
|
||||
}
|
||||
postList.Order = filteredOrder
|
||||
|
||||
// Clear BurnOnReadPosts map as burn-on-read posts should not appear
|
||||
postList.BurnOnReadPosts = make(map[string]*model.Post)
|
||||
|
||||
// Update NextPostId and PrevPostId if they point to removed posts
|
||||
if postList.NextPostId != "" {
|
||||
if _, exists := postList.Posts[postList.NextPostId]; !exists {
|
||||
postList.NextPostId = ""
|
||||
}
|
||||
}
|
||||
if postList.PrevPostId != "" {
|
||||
if _, exists := postList.Posts[postList.PrevPostId]; !exists {
|
||||
postList.PrevPostId = ""
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// revealSingleBurnOnReadPost reveals a single burn-on-read post for a user.
|
||||
// If the post is not a burn-on-read post, it returns the post unchanged.
|
||||
// If the post is expired or inaccessible, it returns an error.
|
||||
func (a *App) revealSingleBurnOnReadPost(rctx request.CTX, post *model.Post, userID string) (*model.Post, *model.AppError) {
|
||||
if post == nil {
|
||||
return nil, model.NewAppError("revealSingleBurnOnReadPost", "app.post.get.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// If not a burn-on-read post, return as-is
|
||||
if post.Type != model.PostTypeBurnOnRead {
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// Check if burn-on-read feature is enabled
|
||||
if !a.Config().FeatureFlags.BurnOnRead || !model.SafeDereference(a.Config().ServiceSettings.EnableBurnOnRead) {
|
||||
// Feature is not enabled, return post as-is
|
||||
return post, nil
|
||||
}
|
||||
|
||||
tmpPostList := model.NewPostList()
|
||||
tmpPostList.AddPost(post)
|
||||
|
||||
postList, appErr := a.revealBurnOnReadPostsForUser(rctx, tmpPostList, userID)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
revealedPost, ok := postList.Posts[post.Id]
|
||||
if !ok {
|
||||
return nil, model.NewAppError("revealSingleBurnOnReadPost", "app.post.get.app_error", nil, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
return revealedPost, nil
|
||||
}
|
||||
|
||||
// revealBurnOnReadPostsForUser processes burn-on-read posts in a post list for a specific user,
|
||||
// revealing posts that the user has access to and handling expired receipts.
|
||||
func (a *App) revealBurnOnReadPostsForUser(rctx request.CTX, postList *model.PostList, userID string) (*model.PostList, *model.AppError) {
|
||||
if postList == nil || postList.BurnOnReadPosts == nil || len(postList.BurnOnReadPosts) == 0 {
|
||||
return postList, nil
|
||||
}
|
||||
|
||||
// Check if burn-on-read feature is enabled
|
||||
if !a.Config().FeatureFlags.BurnOnRead || !model.SafeDereference(a.Config().ServiceSettings.EnableBurnOnRead) {
|
||||
// Feature is not enabled, return postList as-is
|
||||
return postList, nil
|
||||
}
|
||||
|
||||
for _, post := range postList.BurnOnReadPosts {
|
||||
// If user is the author, reveal the post with recipients
|
||||
if post.UserId == userID {
|
||||
if err := a.revealPostForAuthor(rctx, postList, post); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Get user's read receipt for this post
|
||||
receipt, err := a.getUserReadReceipt(rctx, post.Id, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no receipt exists, show unrevealed message
|
||||
if receipt == nil {
|
||||
a.setUnrevealedPost(postList, post.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
// If receipt expired, remove post from list
|
||||
if a.isReceiptExpired(receipt) {
|
||||
a.removePostFromList(postList, post.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
// Reveal post with expiration metadata
|
||||
if err := a.revealPostForUser(rctx, postList, post, receipt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return postList, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,10 +139,18 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, o
|
|||
}
|
||||
|
||||
// Files
|
||||
if fileInfos, _, err := a.getFileMetadataForPost(rctx, post, opts.IsNewPost || opts.IsEditPost, opts.IncludeDeleted); err != nil {
|
||||
rctx.Logger().Warn("Failed to get files for a post", mlog.String("post_id", post.Id), mlog.Err(err))
|
||||
} else {
|
||||
post.Metadata.Files = fileInfos
|
||||
a.preparePostFilesForClient(rctx, post, opts)
|
||||
|
||||
if post.Type == model.PostTypeBurnOnRead {
|
||||
// if metadata expire is not set, it means the post is not revealed yet
|
||||
// so we need to reset the metadata. Or, if the user is the author, we don't reset the metadata.
|
||||
if post.Metadata.ExpireAt == 0 && post.UserId != rctx.Session().UserId {
|
||||
if scheduledPost, ok := rctx.Context().Value(model.PostContextKeyIsScheduledPost).(bool); ok && scheduledPost {
|
||||
// if the post is a scheduled post, we don't reset the metadata
|
||||
} else {
|
||||
post.Metadata = &model.PostMetadata{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opts.IncludePriority && a.IsPostPriorityEnabled() && post.RootId == "" {
|
||||
|
|
@ -164,9 +172,20 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, o
|
|||
return post
|
||||
}
|
||||
|
||||
func (a *App) preparePostFilesForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
|
||||
if fileInfos, _, err := a.getFileMetadataForPost(rctx, post, opts.IsNewPost || opts.IsEditPost, opts.IncludeDeleted); err != nil {
|
||||
rctx.Logger().Warn("Failed to get files for a post", mlog.String("post_id", post.Id), mlog.Err(err))
|
||||
} else {
|
||||
post.Metadata.Files = fileInfos
|
||||
}
|
||||
|
||||
return post
|
||||
}
|
||||
|
||||
func (a *App) PreparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
|
||||
post := a.PreparePostForClient(rctx, originalPost, opts)
|
||||
post = a.getEmbedsAndImages(rctx, post, opts.IsNewPost)
|
||||
post = a.preparePostFilesForClient(rctx, post, opts)
|
||||
return post
|
||||
}
|
||||
|
||||
|
|
@ -679,6 +698,10 @@ func (a *App) getLinkMetadataForPermalink(rctx request.CTX, requestURL string) (
|
|||
return nil, appErr
|
||||
}
|
||||
|
||||
if referencedPost.Type == model.PostTypeBurnOnRead {
|
||||
return nil, model.NewAppError("getLinkMetadataForPermalink", "api.post.get_link_metadata_for_permalink.burn_on_read.app_error", nil, "", http.StatusForbidden)
|
||||
}
|
||||
|
||||
referencedChannel, appErr := a.GetChannel(rctx, referencedPost.ChannelId)
|
||||
if appErr != nil {
|
||||
return nil, appErr
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
|
@ -25,6 +26,13 @@ import (
|
|||
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine/mocks"
|
||||
)
|
||||
|
||||
func enableBoRFeature(th *TestHelper) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.ServiceSettings.EnableBurnOnRead = model.NewPointer(true)
|
||||
})
|
||||
}
|
||||
|
||||
func makePendingPostId(user *model.User) string {
|
||||
return fmt.Sprintf("%s:%s", user.Id, strconv.FormatInt(model.GetMillis(), 10))
|
||||
}
|
||||
|
|
@ -300,15 +308,18 @@ func TestAttachFilesToPost(t *testing.T) {
|
|||
post := th.BasicPost
|
||||
post.FileIds = []string{info1.Id, info2.Id}
|
||||
|
||||
appErr := th.App.attachFilesToPost(th.Context, post)
|
||||
attachedFiles, appErr := th.App.attachFilesToPost(th.Context, post, post.FileIds)
|
||||
assert.Nil(t, appErr)
|
||||
assert.Len(t, attachedFiles, 2)
|
||||
assert.Contains(t, attachedFiles, info1.Id)
|
||||
assert.Contains(t, attachedFiles, info2.Id)
|
||||
|
||||
infos, _, appErr := th.App.GetFileInfosForPost(th.Context, post.Id, false, false)
|
||||
assert.Nil(t, appErr)
|
||||
assert.Len(t, infos, 2)
|
||||
})
|
||||
|
||||
t.Run("should update File.PostIds after failing to add files", func(t *testing.T) {
|
||||
t.Run("should return only successfully attached files after failing to add files", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
info1, err := th.App.Srv().Store().FileInfo().Save(th.Context,
|
||||
|
|
@ -329,8 +340,10 @@ func TestAttachFilesToPost(t *testing.T) {
|
|||
post := th.BasicPost
|
||||
post.FileIds = []string{info1.Id, info2.Id}
|
||||
|
||||
appErr := th.App.attachFilesToPost(th.Context, post)
|
||||
attachedFiles, appErr := th.App.attachFilesToPost(th.Context, post, post.FileIds)
|
||||
assert.Nil(t, appErr)
|
||||
assert.Len(t, attachedFiles, 1)
|
||||
assert.Contains(t, attachedFiles, info2.Id)
|
||||
|
||||
infos, _, appErr := th.App.GetFileInfosForPost(th.Context, post.Id, false, false)
|
||||
assert.Nil(t, appErr)
|
||||
|
|
@ -1313,6 +1326,27 @@ func TestCreatePost(t *testing.T) {
|
|||
require.Nil(t, err)
|
||||
require.NotEmpty(t, createdPost.GetProp(model.PostPropsForceNotification))
|
||||
})
|
||||
|
||||
t.Run("Should remove post file IDs for burn on read posts", func(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
|
||||
})
|
||||
th := Setup(t).InitBasic(t)
|
||||
enableBoRFeature(th)
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "hello world",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
FileIds: []string{model.NewId()},
|
||||
}
|
||||
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.Empty(t, createdPost.FileIds)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchPost(t *testing.T) {
|
||||
|
|
@ -1911,6 +1945,60 @@ func TestUpdatePost(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("should strip client-supplied embeds", func(t *testing.T) {
|
||||
// MM-67055: Verify that client-supplied metadata.embeds are stripped.
|
||||
// This prevents WebSocket message spoofing via permalink embeds.
|
||||
//
|
||||
// Note: Priority and Acknowledgements are stored in separate database tables,
|
||||
// not in post metadata. Shared Channels handles them separately via
|
||||
// syncRemotePriorityMetadata and syncRemoteAcknowledgementsMetadata after
|
||||
// calling UpdatePost. See sync_recv.go::upsertSyncPost
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel)
|
||||
th.Context.Session().UserId = th.BasicUser.Id
|
||||
|
||||
// Create a basic post
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "original message",
|
||||
UserId: th.BasicUser.Id,
|
||||
}
|
||||
createdPost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, err)
|
||||
|
||||
// Try to update with spoofed embeds (the attack vector)
|
||||
updatePost := &model.Post{
|
||||
Id: createdPost.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "updated message",
|
||||
UserId: th.BasicUser.Id,
|
||||
Metadata: &model.PostMetadata{
|
||||
Embeds: []*model.PostEmbed{
|
||||
{
|
||||
Type: model.PostEmbedPermalink,
|
||||
Data: &model.PreviewPost{
|
||||
PostID: "spoofed-post-id",
|
||||
Post: &model.Post{
|
||||
Id: "spoofed-post-id",
|
||||
UserId: th.BasicUser2.Id,
|
||||
Message: "Spoofed message from another user!",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
updatedPost, err := th.App.UpdatePost(th.Context, updatePost, nil)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, updatedPost.Metadata)
|
||||
|
||||
// Verify embeds were stripped
|
||||
assert.Empty(t, updatedPost.Metadata.Embeds, "spoofed embeds should be stripped")
|
||||
})
|
||||
|
||||
t.Run("cannot update post in restricted DM", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
|
@ -4216,6 +4304,11 @@ func TestValidateMoveOrCopy(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPermanentDeletePost(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
|
||||
})
|
||||
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
|
|
@ -4304,6 +4397,71 @@ func TestPermanentDeletePost(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.Len(t, infos, 0)
|
||||
})
|
||||
|
||||
t.Run("should permanently delete a burn-on-read post and its file attachments", func(t *testing.T) {
|
||||
// Enable feature with license
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.ServiceSettings.EnableBurnOnRead = model.NewPointer(true)
|
||||
})
|
||||
|
||||
// Create a burn-on-read post with a file attachment
|
||||
teamID := th.BasicTeam.Id
|
||||
channelID := th.BasicChannel.Id
|
||||
userID := th.BasicUser.Id
|
||||
filename := "burn_on_read_file"
|
||||
data := []byte("burn on read file content")
|
||||
|
||||
info1, err := th.App.DoUploadFile(th.Context, time.Date(2007, 2, 4, 1, 2, 3, 4, time.Local), teamID, channelID, userID, filename, data, true)
|
||||
require.Nil(t, err)
|
||||
|
||||
post := &model.Post{
|
||||
Message: "burn on read message with file",
|
||||
ChannelId: channelID,
|
||||
PendingPostId: model.NewId() + ":" + fmt.Sprint(model.GetMillis()),
|
||||
UserId: userID,
|
||||
CreateAt: 0,
|
||||
FileIds: []string{info1.Id},
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
|
||||
|
||||
post, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
|
||||
require.Nil(t, appErr)
|
||||
require.Equal(t, model.PostTypeBurnOnRead, post.Type)
|
||||
|
||||
// Verify that the post has empty message and file IDs (stored in TemporaryPosts)
|
||||
assert.Empty(t, post.Message)
|
||||
assert.Empty(t, post.FileIds)
|
||||
|
||||
// Verify that TemporaryPost exists with original content
|
||||
tmpPost, tmpErr := th.App.Srv().Store().TemporaryPost().Get(th.Context, post.Id)
|
||||
require.NoError(t, tmpErr)
|
||||
require.NotNil(t, tmpPost)
|
||||
assert.Equal(t, "burn on read message with file", tmpPost.Message)
|
||||
assert.Equal(t, model.StringArray{info1.Id}, tmpPost.FileIDs)
|
||||
|
||||
// Verify file info exists before deletion
|
||||
_, err = th.App.GetFileInfo(th.Context, info1.Id)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Permanently delete the post
|
||||
appErr = th.App.PermanentDeletePost(th.Context, post.Id, userID)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Check that the post can no longer be reached
|
||||
_, err = th.App.GetSinglePost(th.Context, post.Id, true)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
// Check that the file also deleted
|
||||
_, err = th.App.GetFileInfo(th.Context, info1.Id)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
// Verify TemporaryPost is also deleted
|
||||
_, tmpErr = th.App.Srv().Store().TemporaryPost().Get(th.Context, post.Id)
|
||||
assert.Error(t, tmpErr)
|
||||
assert.True(t, store.IsErrNotFound(tmpErr))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendTestMessage(t *testing.T) {
|
||||
|
|
@ -4434,3 +4592,663 @@ func TestPopulateEditHistoryFileMetadata(t *testing.T) {
|
|||
require.Greater(t, post2.Metadata.Files[0].DeleteAt, int64(0))
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterPostsByChannelPermissions(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.GuestAccountsSettings.Enable = true
|
||||
})
|
||||
|
||||
guestUser := th.CreateGuest(t)
|
||||
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, guestUser.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
|
||||
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, guestUser, privateChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
_, appErr = th.App.AddUserToChannel(th.Context, guestUser, th.BasicChannel, false)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
post1 := th.CreatePost(t, th.BasicChannel)
|
||||
post2 := th.CreatePost(t, privateChannel)
|
||||
post3 := th.CreatePost(t, th.BasicChannel)
|
||||
|
||||
t.Run("should filter posts when user has read_channel_content permission", func(t *testing.T) {
|
||||
postList := model.NewPostList()
|
||||
postList.Posts[post1.Id] = post1
|
||||
postList.Posts[post2.Id] = post2
|
||||
postList.Posts[post3.Id] = post3
|
||||
postList.Order = []string{post1.Id, post2.Id, post3.Id}
|
||||
|
||||
appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, postList.Posts, 3)
|
||||
require.Len(t, postList.Order, 3)
|
||||
})
|
||||
|
||||
t.Run("should filter posts when guest has read_channel_content permission", func(t *testing.T) {
|
||||
postList := model.NewPostList()
|
||||
postList.Posts[post1.Id] = post1
|
||||
postList.Posts[post2.Id] = post2
|
||||
postList.Posts[post3.Id] = post3
|
||||
postList.Order = []string{post1.Id, post2.Id, post3.Id}
|
||||
|
||||
appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, guestUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, postList.Posts, 3)
|
||||
require.Len(t, postList.Order, 3)
|
||||
})
|
||||
|
||||
t.Run("should filter posts when guest does not have read_channel_content permission", func(t *testing.T) {
|
||||
channelGuestRole, appErr := th.App.GetRoleByName(th.Context, model.ChannelGuestRoleId)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
originalPermissions := make([]string, len(channelGuestRole.Permissions))
|
||||
copy(originalPermissions, channelGuestRole.Permissions)
|
||||
|
||||
newPermissions := []string{}
|
||||
for _, perm := range channelGuestRole.Permissions {
|
||||
if perm != model.PermissionReadChannelContent.Id && perm != model.PermissionReadChannel.Id {
|
||||
newPermissions = append(newPermissions, perm)
|
||||
}
|
||||
}
|
||||
|
||||
_, appErr = th.App.PatchRole(channelGuestRole, &model.RolePatch{
|
||||
Permissions: &newPermissions,
|
||||
})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
defer func() {
|
||||
_, err := th.App.PatchRole(channelGuestRole, &model.RolePatch{
|
||||
Permissions: &originalPermissions,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
}()
|
||||
|
||||
postList := model.NewPostList()
|
||||
postList.Posts[post1.Id] = post1
|
||||
postList.Posts[post2.Id] = post2
|
||||
postList.Posts[post3.Id] = post3
|
||||
postList.Order = []string{post1.Id, post2.Id, post3.Id}
|
||||
|
||||
appErr = th.App.FilterPostsByChannelPermissions(th.Context, postList, guestUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, postList.Posts, 0)
|
||||
require.Len(t, postList.Order, 0)
|
||||
})
|
||||
|
||||
t.Run("should handle empty post list", func(t *testing.T) {
|
||||
postList := model.NewPostList()
|
||||
appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, postList.Posts, 0)
|
||||
require.Len(t, postList.Order, 0)
|
||||
})
|
||||
|
||||
t.Run("should handle nil post list", func(t *testing.T) {
|
||||
appErr := th.App.FilterPostsByChannelPermissions(th.Context, nil, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
})
|
||||
|
||||
t.Run("should handle posts with empty channel IDs", func(t *testing.T) {
|
||||
postList := model.NewPostList()
|
||||
postWithoutChannel := &model.Post{
|
||||
Id: model.NewId(),
|
||||
ChannelId: "",
|
||||
Message: "test",
|
||||
}
|
||||
postList.Posts[postWithoutChannel.Id] = postWithoutChannel
|
||||
postList.Order = []string{postWithoutChannel.Id}
|
||||
|
||||
appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, postList.Posts, 0)
|
||||
require.Len(t, postList.Order, 0)
|
||||
})
|
||||
|
||||
t.Run("should handle posts from non-existent channels", func(t *testing.T) {
|
||||
postList := model.NewPostList()
|
||||
postWithInvalidChannel := &model.Post{
|
||||
Id: model.NewId(),
|
||||
ChannelId: model.NewId(),
|
||||
Message: "test",
|
||||
}
|
||||
postList.Posts[postWithInvalidChannel.Id] = postWithInvalidChannel
|
||||
postList.Order = []string{postWithInvalidChannel.Id}
|
||||
|
||||
appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, postList.Posts, 0)
|
||||
require.Len(t, postList.Order, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRevealPost(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
|
||||
})
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// Helper to create a burn-on-read post
|
||||
createBurnOnReadPost := func() *model.Post {
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
|
||||
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, createdPost)
|
||||
return createdPost
|
||||
}
|
||||
|
||||
// Helper to create a regular post
|
||||
createRegularPost := func() *model.Post {
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "regular message",
|
||||
}
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, createdPost)
|
||||
return createdPost
|
||||
}
|
||||
|
||||
// Create a second user for testing
|
||||
user2 := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, user2, th.BasicTeam)
|
||||
th.AddUserToChannel(t, user2, th.BasicChannel)
|
||||
|
||||
t.Run("post type non burn on read", func(t *testing.T) {
|
||||
regularPost := createRegularPost()
|
||||
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, regularPost, user2.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
require.Nil(t, revealedPost)
|
||||
require.Equal(t, "app.reveal_post.not_burn_on_read.app_error", appErr.Id)
|
||||
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("post doesn't have required prop", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
// Create a burn-on-read post without expire_at prop
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
|
||||
// First save the post normally (which will add expire_at automatically)
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Now manually remove the expire_at prop to test missing prop scenario
|
||||
createdPost.SetProps(make(model.StringInterface))
|
||||
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
require.Nil(t, revealedPost)
|
||||
require.Equal(t, "app.reveal_post.missing_expire_at.app_error", appErr.Id)
|
||||
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("post with invalid expire_at prop type", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
// Manually set invalid expire_at type
|
||||
post.SetProps(make(model.StringInterface))
|
||||
post.AddProp(model.PostPropsExpireAt, "invalid_string")
|
||||
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
require.Nil(t, revealedPost)
|
||||
require.Equal(t, "app.reveal_post.missing_expire_at.app_error", appErr.Id)
|
||||
})
|
||||
|
||||
t.Run("post with zero expire_at", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
// Manually set zero expire_at
|
||||
post.SetProps(make(model.StringInterface))
|
||||
post.AddProp(model.PostPropsExpireAt, float64(0))
|
||||
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
require.Nil(t, revealedPost)
|
||||
require.Equal(t, "app.reveal_post.missing_expire_at.app_error", appErr.Id)
|
||||
})
|
||||
|
||||
t.Run("read receipt does not exist", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, revealedPost)
|
||||
require.Equal(t, "burn on read message", revealedPost.Message)
|
||||
require.NotNil(t, revealedPost.Metadata)
|
||||
require.NotZero(t, revealedPost.Metadata.ExpireAt)
|
||||
|
||||
// Verify read receipt was created
|
||||
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, post.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, receipt)
|
||||
require.Equal(t, post.Id, receipt.PostID)
|
||||
require.Equal(t, user2.Id, receipt.UserID)
|
||||
require.Equal(t, revealedPost.Metadata.ExpireAt, receipt.ExpireAt)
|
||||
})
|
||||
|
||||
t.Run("read receipt exists and not expired", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
// First reveal to create receipt
|
||||
revealedPost1, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, revealedPost1)
|
||||
require.NotZero(t, revealedPost1.Metadata.ExpireAt)
|
||||
|
||||
// Reveal again - should succeed and return the same post
|
||||
revealedPost2, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, revealedPost2)
|
||||
require.Equal(t, "burn on read message", revealedPost2.Message)
|
||||
require.NotNil(t, revealedPost2.Metadata)
|
||||
require.Equal(t, revealedPost1.Metadata.ExpireAt, revealedPost2.Metadata.ExpireAt)
|
||||
})
|
||||
|
||||
t.Run("read receipt exists but expired", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
// Create an expired read receipt
|
||||
expiredReceipt := &model.ReadReceipt{
|
||||
UserID: user2.Id,
|
||||
PostID: post.Id,
|
||||
ExpireAt: model.GetMillis() - 1000, // Expired 1 second ago
|
||||
}
|
||||
_, err := th.App.Srv().Store().ReadReceipt().Save(th.Context, expiredReceipt)
|
||||
require.NoError(t, err)
|
||||
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
require.Nil(t, revealedPost)
|
||||
require.Equal(t, "app.reveal_post.read_receipt_expired.error", appErr.Id)
|
||||
require.Equal(t, http.StatusForbidden, appErr.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("post expired", func(t *testing.T) {
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
// Manually set expired expire_at
|
||||
post.SetProps(make(model.StringInterface))
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()-1000)
|
||||
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, post, user2.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
require.Nil(t, revealedPost)
|
||||
require.Equal(t, "app.reveal_post.post_expired.app_error", appErr.Id)
|
||||
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("revealed post preserves existing metadata", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
fileBytes := []byte("test")
|
||||
fileInfo, err := th.App.UploadFile(th.Context, fileBytes, th.BasicChannel.Id, "file.txt")
|
||||
require.Nil(t, err)
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
FileIds: model.StringArray{fileInfo.Id},
|
||||
}
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
|
||||
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, createdPost)
|
||||
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, revealedPost)
|
||||
require.NotNil(t, revealedPost.Metadata)
|
||||
require.NotZero(t, revealedPost.Metadata.ExpireAt)
|
||||
require.Len(t, revealedPost.Metadata.Files, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBurnPost(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
|
||||
})
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// feature flag, configuration and license is not checked for this feature
|
||||
// so we set these to enable the feature to create a burn on read post
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
cfg.ServiceSettings.EnableBurnOnRead = model.NewPointer(true)
|
||||
})
|
||||
|
||||
th.AddUserToChannel(t, th.BasicUser, th.BasicChannel) // author of the post
|
||||
th.AddUserToChannel(t, th.BasicUser2, th.BasicChannel) // recipient of the post
|
||||
|
||||
// Helper to create a burn-on-read post
|
||||
createBurnOnReadPost := func() *model.Post {
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000))
|
||||
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, createdPost)
|
||||
return createdPost
|
||||
}
|
||||
|
||||
// Helper to create a regular post
|
||||
createRegularPost := func() *model.Post {
|
||||
post := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "regular message",
|
||||
}
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, createdPost)
|
||||
return createdPost
|
||||
}
|
||||
|
||||
t.Run("burn on read post", func(t *testing.T) {
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
appErr := th.App.BurnPost(th.Context, post, th.BasicUser.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify post is deleted
|
||||
post, err := th.App.Srv().Store().Post().GetSingle(th.Context, post.Id, false)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, post)
|
||||
require.True(t, store.IsErrNotFound(err))
|
||||
})
|
||||
|
||||
t.Run("regular post", func(t *testing.T) {
|
||||
post := createRegularPost()
|
||||
|
||||
appErr := th.App.BurnPost(th.Context, post, th.BasicUser.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, "app.burn_post.not_burn_on_read.app_error", appErr.Id)
|
||||
})
|
||||
|
||||
t.Run("read receipt does not exist", func(t *testing.T) {
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
appErr := th.App.BurnPost(th.Context, post, th.BasicUser2.Id, "")
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, "app.burn_post.not_revealed.app_error", appErr.Id)
|
||||
})
|
||||
|
||||
t.Run("read receipt exists but expired", func(t *testing.T) {
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
// Create an expired read receipt
|
||||
expiredTime := model.GetMillis() - 1000 // Expired 1 second ago
|
||||
expiredReceipt := &model.ReadReceipt{
|
||||
UserID: th.BasicUser2.Id,
|
||||
PostID: post.Id,
|
||||
ExpireAt: expiredTime,
|
||||
}
|
||||
_, err := th.App.Srv().Store().ReadReceipt().Save(th.Context, expiredReceipt)
|
||||
require.NoError(t, err)
|
||||
|
||||
appErr := th.App.BurnPost(th.Context, post, th.BasicUser2.Id, "")
|
||||
require.Nil(t, appErr) // this is a no-op
|
||||
|
||||
// Verify receipt ExpireAt is unchanged (no-op)
|
||||
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, post.Id, th.BasicUser2.Id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expiredTime, receipt.ExpireAt)
|
||||
})
|
||||
|
||||
t.Run("read receipt exists and not expired", func(t *testing.T) {
|
||||
post := createBurnOnReadPost()
|
||||
|
||||
// Create a read receipt that is not expired
|
||||
notExpiredReceipt := &model.ReadReceipt{
|
||||
UserID: th.BasicUser2.Id,
|
||||
PostID: post.Id,
|
||||
ExpireAt: model.GetMillis() + 10000, // Not expired 10 seconds from now
|
||||
}
|
||||
_, err := th.App.Srv().Store().ReadReceipt().Save(th.Context, notExpiredReceipt)
|
||||
require.NoError(t, err)
|
||||
|
||||
appErr := th.App.BurnPost(th.Context, post, th.BasicUser2.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// Verify receipt ExpireAt is updated to current time
|
||||
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, post.Id, th.BasicUser2.Id)
|
||||
require.NoError(t, err)
|
||||
require.LessOrEqual(t, receipt.ExpireAt, model.GetMillis())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) {
|
||||
os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true")
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("MM_FEATUREFLAGS_BURNONREAD")
|
||||
})
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
// Create a second user for testing
|
||||
user2 := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, user2, th.BasicTeam)
|
||||
th.AddUserToChannel(t, user2, th.BasicChannel)
|
||||
|
||||
t.Run("expired burn-on-read post should not be returned in flagged posts", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
// Create a burn-on-read post
|
||||
borPost := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn on read message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*1000)) // 10 seconds
|
||||
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, createdPost)
|
||||
|
||||
// User2 reveals the post
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, revealedPost)
|
||||
|
||||
// User2 saves/flags the post
|
||||
preference := model.Preference{
|
||||
UserId: user2.Id,
|
||||
Category: model.PreferenceCategoryFlaggedPost,
|
||||
Name: createdPost.Id,
|
||||
Value: "true",
|
||||
}
|
||||
err := th.App.Srv().Store().Preference().Save(model.Preferences{preference})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify post appears in flagged posts before expiration
|
||||
flaggedPosts, appErr := th.App.GetFlaggedPosts(th.Context, user2.Id, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, flaggedPosts)
|
||||
require.Contains(t, flaggedPosts.Order, createdPost.Id)
|
||||
require.NotNil(t, flaggedPosts.Posts[createdPost.Id])
|
||||
|
||||
// Simulate expiration by updating the receipt's ExpireAt to the past
|
||||
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, createdPost.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, receipt)
|
||||
|
||||
receipt.ExpireAt = model.GetMillis() - 1000 // 1 second in the past
|
||||
_, err = th.App.Srv().Store().ReadReceipt().Update(th.Context, receipt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get flagged posts again - expired post should be filtered out
|
||||
flaggedPosts, appErr = th.App.GetFlaggedPosts(th.Context, user2.Id, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, flaggedPosts)
|
||||
require.NotContains(t, flaggedPosts.Order, createdPost.Id, "Expired burn-on-read post should not be in flagged posts")
|
||||
require.Nil(t, flaggedPosts.Posts[createdPost.Id], "Expired burn-on-read post should not be in posts map")
|
||||
})
|
||||
|
||||
t.Run("expired burn-on-read post should not be returned in flagged posts for team", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
// Create a burn-on-read post
|
||||
borPost := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn on read team message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*1000))
|
||||
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// User2 reveals and flags the post
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, revealedPost)
|
||||
|
||||
preference := model.Preference{
|
||||
UserId: user2.Id,
|
||||
Category: model.PreferenceCategoryFlaggedPost,
|
||||
Name: createdPost.Id,
|
||||
Value: "true",
|
||||
}
|
||||
err := th.App.Srv().Store().Preference().Save(model.Preferences{preference})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Expire the receipt
|
||||
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, createdPost.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
receipt.ExpireAt = model.GetMillis() - 1000
|
||||
_, err = th.App.Srv().Store().ReadReceipt().Update(th.Context, receipt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get flagged posts for team
|
||||
flaggedPosts, appErr := th.App.GetFlaggedPostsForTeam(th.Context, user2.Id, th.BasicTeam.Id, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.NotContains(t, flaggedPosts.Order, createdPost.Id)
|
||||
})
|
||||
|
||||
t.Run("expired burn-on-read post should not be returned in flagged posts for channel", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
// Create a burn-on-read post
|
||||
borPost := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn on read channel message",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*1000))
|
||||
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// User2 reveals and flags the post
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, revealedPost)
|
||||
|
||||
preference := model.Preference{
|
||||
UserId: user2.Id,
|
||||
Category: model.PreferenceCategoryFlaggedPost,
|
||||
Name: createdPost.Id,
|
||||
Value: "true",
|
||||
}
|
||||
err := th.App.Srv().Store().Preference().Save(model.Preferences{preference})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Expire the receipt
|
||||
receipt, err := th.App.Srv().Store().ReadReceipt().Get(th.Context, createdPost.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
receipt.ExpireAt = model.GetMillis() - 1000
|
||||
_, err = th.App.Srv().Store().ReadReceipt().Update(th.Context, receipt)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get flagged posts for channel
|
||||
flaggedPosts, appErr := th.App.GetFlaggedPostsForChannel(th.Context, user2.Id, th.BasicChannel.Id, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.NotContains(t, flaggedPosts.Order, createdPost.Id)
|
||||
})
|
||||
|
||||
t.Run("non-expired burn-on-read post should appear in flagged posts", func(t *testing.T) {
|
||||
enableBoRFeature(th)
|
||||
|
||||
// Create a burn-on-read post with long expiration
|
||||
borPost := &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
Message: "burn on read message still valid",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
}
|
||||
borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(3600*1000)) // 1 hour
|
||||
|
||||
createdPost, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{})
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// User2 reveals and flags the post
|
||||
revealedPost, appErr := th.App.RevealPost(th.Context, createdPost, user2.Id, "")
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, revealedPost)
|
||||
|
||||
preference := model.Preference{
|
||||
UserId: user2.Id,
|
||||
Category: model.PreferenceCategoryFlaggedPost,
|
||||
Name: createdPost.Id,
|
||||
Value: "true",
|
||||
}
|
||||
err := th.App.Srv().Store().Preference().Save(model.Preferences{preference})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get flagged posts - post should be present
|
||||
flaggedPosts, appErr := th.App.GetFlaggedPosts(th.Context, user2.Id, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.Contains(t, flaggedPosts.Order, createdPost.Id, "Non-expired burn-on-read post should be in flagged posts")
|
||||
require.NotNil(t, flaggedPosts.Posts[createdPost.Id])
|
||||
|
||||
// Verify metadata is populated correctly
|
||||
post := flaggedPosts.Posts[createdPost.Id]
|
||||
require.NotNil(t, post.Metadata)
|
||||
require.NotZero(t, post.Metadata.ExpireAt)
|
||||
require.Greater(t, post.Metadata.ExpireAt, model.GetMillis())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/mattermost/mattermost/server/public/plugin"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
func (a *App) SaveReactionForPost(rctx request.CTX, reaction *model.Reaction) (*model.Reaction, *model.AppError) {
|
||||
|
|
@ -20,6 +21,16 @@ func (a *App) SaveReactionForPost(rctx request.CTX, reaction *model.Reaction) (*
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if post.Type == model.PostTypeBurnOnRead && post.UserId != reaction.UserId {
|
||||
receipt, err := a.Srv().Store().ReadReceipt().Get(rctx, post.Id, reaction.UserId)
|
||||
if err != nil && !store.IsErrNotFound(err) {
|
||||
return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
if receipt == nil || receipt.ExpireAt < model.GetMillis() {
|
||||
return nil, model.NewAppError("SaveReactionForPost", "api.reaction.save.burn_on_read.app_error", nil, "", http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether this is a valid emoji
|
||||
if _, ok := model.GetSystemEmojiId(reaction.EmojiName); !ok {
|
||||
if _, emojiErr := a.GetEmojiByName(rctx, reaction.EmojiName); emojiErr != nil {
|
||||
|
|
@ -194,5 +205,11 @@ func (a *App) sendReactionEvent(rctx request.CTX, event model.WebsocketEventType
|
|||
rctx.Logger().Warn("Failed to encode reaction to JSON", mlog.Err(err))
|
||||
}
|
||||
message.Add("reaction", string(reactionJSON))
|
||||
|
||||
// For burn-on-read posts, filter recipients based on read receipts
|
||||
if post.Type == model.PostTypeBurnOnRead {
|
||||
useBurnOnReadReactionHook(message, post.UserId, post.Id)
|
||||
}
|
||||
|
||||
a.Publish(message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost
|
|||
return nil, validationErr
|
||||
}
|
||||
|
||||
// validate the scheduled post belongs to the said user
|
||||
existingScheduledPost, err := a.Srv().Store().ScheduledPost().Get(scheduledPost.Id)
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusInternalServerError).Wrap(err)
|
||||
|
|
@ -83,10 +82,6 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost
|
|||
return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.existing_scheduled_post.not_exist", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
if existingScheduledPost.UserId != userId {
|
||||
return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.update_permission.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusForbidden)
|
||||
}
|
||||
|
||||
// This step is not required for update but is useful as we want to return the
|
||||
// updated scheduled post. It's better to do this before calling update than after.
|
||||
scheduledPost.RestoreNonUpdatableFields(existingScheduledPost)
|
||||
|
|
@ -110,10 +105,6 @@ func (a *App) DeleteScheduledPost(rctx request.CTX, userId, scheduledPostId, con
|
|||
return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.existing_scheduled_post.not_exist", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
if scheduledPost.UserId != userId {
|
||||
return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.delete_permission.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusForbidden)
|
||||
}
|
||||
|
||||
if err := a.Srv().Store().ScheduledPost().PermanentlyDeleteScheduledPosts([]string{scheduledPostId}); err != nil {
|
||||
return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.delete_error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -189,11 +190,10 @@ func (a *App) postScheduledPost(rctx request.CTX, scheduledPost *model.Scheduled
|
|||
return scheduledPost, err
|
||||
}
|
||||
|
||||
createPostFlags := model.CreatePostFlags{
|
||||
_, appErr = a.CreatePost(rctx.WithContext(context.WithValue(rctx.Context(), model.PostContextKeyIsScheduledPost, true)), post, channel, model.CreatePostFlags{
|
||||
TriggerWebhooks: true,
|
||||
SetOnline: false,
|
||||
}
|
||||
_, appErr = a.CreatePost(rctx, post, channel, createPostFlags)
|
||||
})
|
||||
if appErr != nil {
|
||||
rctx.Logger().Error(
|
||||
"App.processScheduledPostBatch: failed to post scheduled post",
|
||||
|
|
|
|||
|
|
@ -567,54 +567,6 @@ func TestUpdateScheduledPost(t *testing.T) {
|
|||
require.Equal(t, "Updated Message!!!", updatedScheduledPost.Message)
|
||||
})
|
||||
|
||||
t.Run("should ot be allowed to updated a scheduled post not belonging to the user", func(t *testing.T) {
|
||||
// first we'll create a scheduled post
|
||||
userId := model.NewId()
|
||||
|
||||
channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{
|
||||
Name: model.NewId(),
|
||||
DisplayName: "Channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
}, 1000)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{
|
||||
ChannelId: channel.Id,
|
||||
UserId: userId,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
SchemeGuest: false,
|
||||
SchemeUser: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
_ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis())
|
||||
_ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId)
|
||||
}()
|
||||
|
||||
scheduledPost := &model.ScheduledPost{
|
||||
Draft: model.Draft{
|
||||
CreateAt: model.GetMillis(),
|
||||
UserId: userId,
|
||||
ChannelId: channel.Id,
|
||||
Message: "this is a scheduled post",
|
||||
},
|
||||
ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future
|
||||
}
|
||||
createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost, user1ConnID)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, createdScheduledPost)
|
||||
|
||||
// now we'll try updating it
|
||||
newScheduledAtTime := model.GetMillis() + 9999999
|
||||
createdScheduledPost.ScheduledAt = newScheduledAtTime
|
||||
createdScheduledPost.Message = "Updated Message!!!"
|
||||
updatedScheduledPost, appErr := th.App.UpdateScheduledPost(th.Context, th.BasicUser2.Id, createdScheduledPost, user1ConnID)
|
||||
require.NotNil(t, appErr)
|
||||
require.Equal(t, http.StatusForbidden, appErr.StatusCode)
|
||||
require.Nil(t, updatedScheduledPost)
|
||||
})
|
||||
|
||||
t.Run("should only allow updating limited fields", func(t *testing.T) {
|
||||
// first we'll create a scheduled post
|
||||
userId := model.NewId()
|
||||
|
|
@ -721,6 +673,94 @@ func TestUpdateScheduledPost(t *testing.T) {
|
|||
require.NotNil(t, appErr)
|
||||
require.Nil(t, updatedScheduledPost)
|
||||
})
|
||||
|
||||
t.Run("burn on read scheduled post - verify type is preserved on create and update", func(t *testing.T) {
|
||||
// Create a scheduled post with burn on read type
|
||||
userId := model.NewId()
|
||||
|
||||
channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{
|
||||
Name: model.NewId(),
|
||||
DisplayName: "Channel",
|
||||
Type: model.ChannelTypeOpen,
|
||||
}, 1000)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{
|
||||
ChannelId: channel.Id,
|
||||
UserId: userId,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
SchemeGuest: false,
|
||||
SchemeUser: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
_ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis())
|
||||
_ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId)
|
||||
}()
|
||||
|
||||
scheduledPost := &model.ScheduledPost{
|
||||
Draft: model.Draft{
|
||||
CreateAt: model.GetMillis(),
|
||||
UserId: userId,
|
||||
ChannelId: channel.Id,
|
||||
Message: "this is a burn on read scheduled post",
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
},
|
||||
ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future
|
||||
}
|
||||
|
||||
createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost, user1ConnID)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, createdScheduledPost)
|
||||
|
||||
require.Equal(t, model.PostTypeBurnOnRead, createdScheduledPost.Type)
|
||||
|
||||
fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(createdScheduledPost.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fetchedScheduledPost)
|
||||
require.Equal(t, model.PostTypeBurnOnRead, fetchedScheduledPost.Type)
|
||||
|
||||
createdScheduledPost.Message = "Updated burn on read message"
|
||||
createdScheduledPost.ScheduledAt = model.GetMillis() + 200000
|
||||
// Try to change the type - it should NOT change
|
||||
createdScheduledPost.Type = ""
|
||||
|
||||
updatedScheduledPost, appErr := th.App.UpdateScheduledPost(th.Context, userId, createdScheduledPost, user1ConnID)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, updatedScheduledPost)
|
||||
|
||||
// Verify the type is NOT changed - it should still be burn on read
|
||||
require.Equal(t, model.PostTypeBurnOnRead, updatedScheduledPost.Type)
|
||||
require.Equal(t, "Updated burn on read message", updatedScheduledPost.Message)
|
||||
|
||||
// Fetch again from store to verify the type is still burn on read in the database
|
||||
reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(createdScheduledPost.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reFetchedScheduledPost)
|
||||
require.Equal(t, model.PostTypeBurnOnRead, reFetchedScheduledPost.Type)
|
||||
|
||||
// Try another update with a different type value - verify the type is still NOT changed
|
||||
existingPost, err := th.Server.Store().ScheduledPost().Get(createdScheduledPost.Id)
|
||||
require.NoError(t, err)
|
||||
existingPost.Message = "Another update attempt"
|
||||
existingPost.ScheduledAt = model.GetMillis() + 300000
|
||||
// Try to change the type to a different value - verify it doesn't change
|
||||
existingPost.Type = "some_other_type"
|
||||
|
||||
updatedScheduledPost2, appErr := th.App.UpdateScheduledPost(th.Context, userId, existingPost, user1ConnID)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, updatedScheduledPost2)
|
||||
|
||||
// Verify the type remains burn on read even when we try to change it to a different value
|
||||
require.Equal(t, model.PostTypeBurnOnRead, updatedScheduledPost2.Type)
|
||||
|
||||
// Final verification from store
|
||||
finalFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(createdScheduledPost.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, finalFetchedScheduledPost)
|
||||
require.Equal(t, model.PostTypeBurnOnRead, finalFetchedScheduledPost.Type)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteScheduledPost(t *testing.T) {
|
||||
|
|
@ -765,41 +805,6 @@ func TestDeleteScheduledPost(t *testing.T) {
|
|||
require.Nil(t, reFetchedScheduledPost)
|
||||
})
|
||||
|
||||
t.Run("should not allow deleting someone else's scheduled post", func(t *testing.T) {
|
||||
// first we'll create a scheduled post
|
||||
scheduledPost := &model.ScheduledPost{
|
||||
Draft: model.Draft{
|
||||
CreateAt: model.GetMillis(),
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "this is a scheduled post",
|
||||
},
|
||||
ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future
|
||||
}
|
||||
createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost, user1ConnID)
|
||||
require.Nil(t, appErr)
|
||||
require.NotNil(t, createdScheduledPost)
|
||||
|
||||
fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fetchedScheduledPost)
|
||||
require.Equal(t, createdScheduledPost.Id, fetchedScheduledPost.Id)
|
||||
require.Equal(t, createdScheduledPost.Message, fetchedScheduledPost.Message)
|
||||
|
||||
// now we'll delete it
|
||||
var deletedScheduledPost *model.ScheduledPost
|
||||
deletedScheduledPost, appErr = th.App.DeleteScheduledPost(th.Context, th.BasicUser2.Id, scheduledPost.Id, "connection_id")
|
||||
require.NotNil(t, appErr)
|
||||
require.Nil(t, deletedScheduledPost)
|
||||
|
||||
// try to fetch it again
|
||||
reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reFetchedScheduledPost)
|
||||
require.Equal(t, createdScheduledPost.Id, reFetchedScheduledPost.Id)
|
||||
require.Equal(t, createdScheduledPost.Message, reFetchedScheduledPost.Message)
|
||||
})
|
||||
|
||||
t.Run("should producer error when deleting non existing scheduled post", func(t *testing.T) {
|
||||
var deletedScheduledPost *model.ScheduledPost
|
||||
deletedScheduledPost, appErr := th.App.DeleteScheduledPost(th.Context, th.BasicUser.Id, model.NewId(), "connection_id")
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import (
|
|||
"github.com/mattermost/mattermost/server/v8/channels/jobs/cleanup_desktop_tokens"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/delete_dms_preferences_migration"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/delete_empty_drafts_migration"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/delete_expired_posts"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/delete_orphan_drafts_migration"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/expirynotify"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/export_delete"
|
||||
|
|
@ -1623,15 +1624,36 @@ func (s *Server) initJobs() {
|
|||
delete_dms_preferences_migration.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))),
|
||||
nil)
|
||||
|
||||
s.Jobs.RegisterJobType(
|
||||
model.JobTypeDeleteExpiredPosts,
|
||||
delete_expired_posts.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))),
|
||||
delete_expired_posts.MakeScheduler(s.Jobs),
|
||||
)
|
||||
|
||||
s.platform.Jobs = s.Jobs
|
||||
}
|
||||
|
||||
// ServerId returns the unique identifier for an installation of Mattermost servers.
|
||||
//
|
||||
// It is also known as the "telemetry id" or the "diagnostic id". Once generated
|
||||
// on first start, the value is persisted to the database and should remain static
|
||||
// for the lifetime of the installation.
|
||||
//
|
||||
// Only one server in a cluster will succeed in writing to the database on first
|
||||
// start, after which the other servers will converge on the same value.
|
||||
func (s *Server) ServerId() string {
|
||||
props, err := s.Store().System().Get()
|
||||
if s.telemetryService != nil && s.telemetryService.ServerID != "" {
|
||||
return s.telemetryService.ServerID
|
||||
}
|
||||
|
||||
prop, err := s.Store().System().GetByNameWithContext(
|
||||
store.RequestContextWithMaster(request.EmptyContext(s.Log())),
|
||||
model.SystemServerId,
|
||||
)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return props[model.SystemServerId]
|
||||
return prop.Value
|
||||
}
|
||||
|
||||
func (s *Server) HTTPService() httpservice.HTTPService {
|
||||
|
|
|
|||
|
|
@ -375,6 +375,7 @@ func (a *App) sendTeamEvent(team *model.Team, event model.WebsocketEventType) *m
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetSchemeRolesForTeam Gets the scheme roles for a team, they may be empty, default or custom permissions based on the scheme.
|
||||
func (a *App) GetSchemeRolesForTeam(teamID string) (string, string, string, *model.AppError) {
|
||||
team, err := a.GetTeam(teamID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -195,7 +195,6 @@ func (a *App) AuthenticateUserForGuestMagicLink(rctx request.CTX, tokenString st
|
|||
Email: email,
|
||||
EmailVerified: true,
|
||||
Username: username,
|
||||
Password: model.NewId(), // Random password - user won't use it
|
||||
AuthService: model.UserAuthServiceMagicLink,
|
||||
}
|
||||
|
||||
|
|
@ -1083,6 +1082,10 @@ func (a *App) UpdatePasswordAsUser(rctx request.CTX, userID, currentPassword, ne
|
|||
return model.NewAppError("updatePassword", "api.user.update_password.oauth.app_error", nil, "auth_service="+user.AuthService, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if user.IsMagicLinkEnabled() {
|
||||
return model.NewAppError("updatePassword", "api.user.update_password.magic_link.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if err := a.DoubleCheckPassword(rctx, user, currentPassword); err != nil {
|
||||
if err.Id == "api.user.check_user_password.invalid.app_error" {
|
||||
err = model.NewAppError("updatePassword", "api.user.update_password.incorrect.app_error", nil, "", http.StatusBadRequest)
|
||||
|
|
@ -1625,6 +1628,10 @@ func (a *App) UpdatePassword(rctx request.CTX, user *model.User, newPassword str
|
|||
return model.NewAppError("UpdatePassword", "api.user.update_password.failed.app_error", nil, "", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if user.IsMagicLinkEnabled() {
|
||||
return model.NewAppError("UpdatePassword", "api.user.update_password.magic_link.app_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
hashedPassword, err := hashers.Hash(newPassword)
|
||||
if err != nil {
|
||||
// can't be password length (checked in IsPasswordValid)
|
||||
|
|
@ -1742,7 +1749,7 @@ func (a *App) resetPasswordFromToken(rctx request.CTX, userSuppliedTokenString,
|
|||
return model.NewAppError("ResetPasswordFromCode", "api.user.reset_password.sso.app_error", nil, "userId="+user.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if user.IsGuest() && user.IsMagicLinkEnabled() {
|
||||
if user.IsMagicLinkEnabled() {
|
||||
return model.NewAppError("ResetPasswordFromCode", "api.user.send_password_reset.guest_magic_link.app_error", nil, "userId="+user.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
|
|
@ -1779,7 +1786,7 @@ func (a *App) SendPasswordReset(rctx request.CTX, email string, siteURL string)
|
|||
return false, model.NewAppError("SendPasswordReset", "api.user.send_password_reset.sso.app_error", nil, "userId="+user.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if user.IsGuest() && user.IsMagicLinkEnabled() {
|
||||
if user.IsMagicLinkEnabled() {
|
||||
return false, model.NewAppError("SendPasswordReset", "api.user.send_password_reset.guest_magic_link.app_error", nil, "userId="+user.Id, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,22 +11,27 @@ import (
|
|||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/app/platform"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
broadcastAddMentions = "add_mentions"
|
||||
broadcastAddFollowers = "add_followers"
|
||||
broadcastPostedAck = "posted_ack"
|
||||
broadcastPermalink = "permalink"
|
||||
broadcastAddMentions = "add_mentions"
|
||||
broadcastAddFollowers = "add_followers"
|
||||
broadcastPostedAck = "posted_ack"
|
||||
broadcastPermalink = "permalink"
|
||||
broadcastBurnOnRead = "burn_on_read"
|
||||
broadcastBurnOnReadReaction = "burn_on_read_reaction"
|
||||
)
|
||||
|
||||
func (s *Server) makeBroadcastHooks() map[string]platform.BroadcastHook {
|
||||
return map[string]platform.BroadcastHook{
|
||||
broadcastAddMentions: &addMentionsBroadcastHook{},
|
||||
broadcastAddFollowers: &addFollowersBroadcastHook{},
|
||||
broadcastPostedAck: &postedAckBroadcastHook{},
|
||||
broadcastPermalink: &permalinkBroadcastHook{},
|
||||
broadcastAddMentions: &addMentionsBroadcastHook{},
|
||||
broadcastAddFollowers: &addFollowersBroadcastHook{},
|
||||
broadcastPostedAck: &postedAckBroadcastHook{},
|
||||
broadcastPermalink: &permalinkBroadcastHook{},
|
||||
broadcastBurnOnRead: &burnOnReadBroadcastHook{},
|
||||
broadcastBurnOnReadReaction: &burnOnReadReactionBroadcastHook{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,13 +138,22 @@ func (h *postedAckBroadcastHook) Process(msg *platform.HookedWebSocketEvent, web
|
|||
return nil
|
||||
}
|
||||
|
||||
func usePermalinkHook(message *model.WebSocketEvent, previewChannel *model.Channel, postJSON string) {
|
||||
func usePermalinkHook(message *model.WebSocketEvent, authorID string, previewChannel *model.Channel, postJSON string) {
|
||||
message.GetBroadcast().AddHook(broadcastPermalink, map[string]any{
|
||||
"author_id": authorID,
|
||||
"preview_channel": previewChannel,
|
||||
"post_json": postJSON,
|
||||
})
|
||||
}
|
||||
|
||||
func useBurnOnReadHook(message *model.WebSocketEvent, authorID string, revealedPostJSON, postJSON string) {
|
||||
message.GetBroadcast().AddHook(broadcastBurnOnRead, map[string]any{
|
||||
"author_id": authorID,
|
||||
"post_json": postJSON,
|
||||
"revealed_post_json": revealedPostJSON,
|
||||
})
|
||||
}
|
||||
|
||||
type permalinkBroadcastHook struct{}
|
||||
|
||||
// Process adds the post medata from usePermalinkHook to the websocket event
|
||||
|
|
@ -167,6 +181,89 @@ func (h *permalinkBroadcastHook) Process(msg *platform.HookedWebSocketEvent, web
|
|||
return nil
|
||||
}
|
||||
|
||||
type burnOnReadBroadcastHook struct{}
|
||||
|
||||
func (h *burnOnReadBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
||||
userID := webConn.UserId
|
||||
authorID, err := getTypedArg[string](args, "author_id")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Invalid author_id value passed to burnOnReadBroadcastHook")
|
||||
}
|
||||
if userID == authorID {
|
||||
postJSON, tErr := getTypedArg[string](args, "revealed_post_json")
|
||||
if tErr != nil {
|
||||
return errors.Wrap(tErr, "Invalid revealed_post_json value passed to burnOnReadBroadcastHook")
|
||||
}
|
||||
msg.Add("post", postJSON)
|
||||
return nil
|
||||
}
|
||||
|
||||
postJSON, err := getTypedArg[string](args, "post_json")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Invalid post_json value passed to burnOnReadBroadcastHook")
|
||||
}
|
||||
|
||||
var post model.Post
|
||||
err = json.Unmarshal([]byte(postJSON), &post)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Invalid post value passed to burnOnReadBroadcastHook")
|
||||
}
|
||||
post.Metadata.Embeds = []*model.PostEmbed{}
|
||||
post.Metadata.Emojis = []*model.Emoji{}
|
||||
post.Metadata.Reactions = []*model.Reaction{}
|
||||
postJSON, err = post.ToJSON()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Invalid post value passed to burnOnReadBroadcastHook")
|
||||
}
|
||||
|
||||
msg.Add("post", postJSON)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type burnOnReadReactionBroadcastHook struct{}
|
||||
|
||||
func (h *burnOnReadReactionBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
|
||||
userID := webConn.UserId
|
||||
authorID, err := getTypedArg[string](args, "author_id")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Invalid author_id value passed to burnOnReadReactionBroadcastHook")
|
||||
}
|
||||
|
||||
// If user is the author, they can always see reactions
|
||||
if userID == authorID {
|
||||
return nil
|
||||
}
|
||||
|
||||
postID, err := getTypedArg[string](args, "post_id")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Invalid post_id value passed to burnOnReadReactionBroadcastHook")
|
||||
}
|
||||
|
||||
// Check if user has a valid read receipt
|
||||
rctx := request.EmptyContext(webConn.Platform.Log())
|
||||
receipt, err := webConn.Platform.Store.ReadReceipt().Get(rctx, postID, userID)
|
||||
if err != nil && !store.IsErrNotFound(err) {
|
||||
return errors.Wrap(err, "Failed to get read receipt in burnOnReadReactionBroadcastHook")
|
||||
}
|
||||
|
||||
// If no receipt or receipt expired, remove reaction data
|
||||
if receipt == nil || receipt.ExpireAt < model.GetMillis() {
|
||||
msg.Event().Reject()
|
||||
return nil
|
||||
}
|
||||
|
||||
// User has valid receipt, allow the reaction event
|
||||
return nil
|
||||
}
|
||||
|
||||
func useBurnOnReadReactionHook(message *model.WebSocketEvent, authorID, postID string) {
|
||||
message.GetBroadcast().AddHook(broadcastBurnOnReadReaction, map[string]any{
|
||||
"author_id": authorID,
|
||||
"post_id": postID,
|
||||
})
|
||||
}
|
||||
|
||||
func incrementWebsocketCounter(wc *platform.WebConn) {
|
||||
if wc.Platform.Metrics() == nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ func (a *App) invalidateCacheForChannelPosts(channelID string) {
|
|||
a.Srv().Platform().InvalidateCacheForChannelPosts(channelID)
|
||||
}
|
||||
|
||||
func (a *App) invalidateCacheForReadReceipts(postID string) {
|
||||
a.Srv().Platform().InvalidateCacheForReadReceipts(postID)
|
||||
}
|
||||
|
||||
func (a *App) invalidateCacheForTemporaryPost(id string) {
|
||||
a.Srv().Platform().InvalidateCacheForTemporaryPost(id)
|
||||
}
|
||||
|
||||
func (a *App) InvalidateCacheForUser(userID string) {
|
||||
a.Srv().Platform().InvalidateCacheForUser(userID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,3 +291,5 @@ channels/db/migrations/postgres/000146_add_audience_and_resource_to_oauth.down.s
|
|||
channels/db/migrations/postgres/000146_add_audience_and_resource_to_oauth.up.sql
|
||||
channels/db/migrations/postgres/000147_create_autotranslation_tables.down.sql
|
||||
channels/db/migrations/postgres/000147_create_autotranslation_tables.up.sql
|
||||
channels/db/migrations/postgres/000148_add_burn_on_read_messages.down.sql
|
||||
channels/db/migrations/postgres/000148_add_burn_on_read_messages.up.sql
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
DROP INDEX IF EXISTS idx_read_receipts_post_id;
|
||||
DROP INDEX IF EXISTS idx_read_receipts_user_id_post_id_expire_at;
|
||||
DROP TABLE IF EXISTS ReadReceipts;
|
||||
|
||||
DROP INDEX IF EXISTS idx_temporary_posts_expire_at;
|
||||
DROP TABLE IF EXISTS TemporaryPosts;
|
||||
|
||||
ALTER TABLE drafts DROP COLUMN IF EXISTS type;
|
||||
ALTER TABLE scheduledposts DROP COLUMN IF EXISTS type;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
CREATE TABLE IF NOT EXISTS ReadReceipts (
|
||||
PostId VARCHAR(26) NOT NULL,
|
||||
UserId VARCHAR(26) NOT NULL,
|
||||
ExpireAt bigint NOT NULL,
|
||||
PRIMARY KEY (PostId, UserId)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_read_receipts_post_id ON ReadReceipts(PostId);
|
||||
CREATE INDEX IF NOT EXISTS idx_read_receipts_user_id_post_id_expire_at ON ReadReceipts(UserId, PostId, ExpireAt);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS TemporaryPosts (
|
||||
PostId VARCHAR(26) PRIMARY KEY,
|
||||
Type VARCHAR(26) NOT NULL,
|
||||
ExpireAt BIGINT NOT NULL,
|
||||
Message VARCHAR(65535),
|
||||
FileIds VARCHAR(300)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_temporary_posts_expire_at ON TemporaryPosts(expireat);
|
||||
|
||||
ALTER TABLE drafts ADD COLUMN IF NOT EXISTS Type text;
|
||||
ALTER TABLE scheduledposts ADD COLUMN IF NOT EXISTS Type text;
|
||||
23
server/channels/jobs/delete_expired_posts/scheduler.go
Normal file
23
server/channels/jobs/delete_expired_posts/scheduler.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package delete_expired_posts
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs"
|
||||
)
|
||||
|
||||
func MakeScheduler(jobServer *jobs.JobServer) *jobs.PeriodicScheduler {
|
||||
isEnabled := func(cfg *model.Config) bool {
|
||||
featureFlagEnabled := cfg.FeatureFlags.BurnOnRead
|
||||
serviceSettingEnabled := model.SafeDereference(cfg.ServiceSettings.EnableBurnOnRead)
|
||||
|
||||
return featureFlagEnabled && serviceSettingEnabled
|
||||
}
|
||||
|
||||
schedFreq := time.Duration(model.SafeDereference(jobServer.Config().ServiceSettings.BurnOnReadSchedulerFrequencySeconds)) * time.Second
|
||||
return jobs.NewPeriodicScheduler(jobServer, model.JobTypeDeleteExpiredPosts, schedFreq, isEnabled)
|
||||
}
|
||||
53
server/channels/jobs/delete_expired_posts/worker.go
Normal file
53
server/channels/jobs/delete_expired_posts/worker.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package delete_expired_posts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
type AppIface interface {
|
||||
DeletePost(rctx request.CTX, postID, deleteByID string) (*model.Post, *model.AppError)
|
||||
PermanentDeletePost(rctx request.CTX, postID, deleteByID string) *model.AppError
|
||||
}
|
||||
|
||||
func MakeWorker(jobServer *jobs.JobServer, store store.Store, app AppIface) *jobs.SimpleWorker {
|
||||
const workerName = "DeleteExpiredPosts"
|
||||
|
||||
isEnabled := func(cfg *model.Config) bool {
|
||||
return model.SafeDereference(cfg.ServiceSettings.EnableBurnOnRead)
|
||||
}
|
||||
execute := func(logger mlog.LoggerIFace, job *model.Job) error {
|
||||
ids, err := store.TemporaryPost().GetExpiredPosts(request.EmptyContext(logger))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deletedPostIDs := make([]string, 0)
|
||||
for _, id := range ids {
|
||||
appErr := app.PermanentDeletePost(request.EmptyContext(logger), id, "")
|
||||
if appErr != nil {
|
||||
logger.Error("Failed to delete expired post", mlog.Err(appErr), mlog.String("post_id", id))
|
||||
continue
|
||||
}
|
||||
deletedPostIDs = append(deletedPostIDs, id)
|
||||
}
|
||||
if job.Data == nil {
|
||||
job.Data = make(model.StringMap)
|
||||
}
|
||||
deletedPostIDsJSON, err := json.Marshal(deletedPostIDs)
|
||||
if err != nil {
|
||||
logger.Error("Failed to marshal deleted post IDs", mlog.Err(err))
|
||||
return err
|
||||
}
|
||||
job.Data["deleted_post_ids"] = string(deletedPostIDsJSON)
|
||||
return nil
|
||||
}
|
||||
return jobs.NewSimpleWorker(workerName, jobServer, execute, isEnabled)
|
||||
}
|
||||
|
|
@ -183,3 +183,8 @@ func (e *ErrUniqueConstraint) Error() string {
|
|||
}
|
||||
return fmt.Sprintf(tmpl, strings.Join(e.Columns, ","))
|
||||
}
|
||||
|
||||
func IsErrNotFound(err error) bool {
|
||||
_, ok := err.(*ErrNotFound)
|
||||
return ok
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ const (
|
|||
UserAutoTranslationCacheSec = 15 * 60
|
||||
|
||||
ContentFlaggingCacheSize = 100
|
||||
|
||||
ReadReceiptCacheSize = 50000
|
||||
|
||||
TemporaryPostCacheSize = 10000
|
||||
TemporaryPostCacheMinutes = 60
|
||||
)
|
||||
|
||||
var clearCacheMessageData = []byte("")
|
||||
|
|
@ -133,8 +138,16 @@ type LocalCacheStore struct {
|
|||
|
||||
autotranslation LocalCacheAutoTranslationStore
|
||||
userAutoTranslationCache cache.Cache
|
||||
contentFlagging LocalCacheContentFlaggingStore
|
||||
contentFlaggingCache cache.Cache
|
||||
|
||||
contentFlagging LocalCacheContentFlaggingStore
|
||||
contentFlaggingCache cache.Cache
|
||||
|
||||
readReceipt LocalCacheReadReceiptStore
|
||||
readReceiptCache cache.Cache
|
||||
readReceiptPostReadersCache cache.Cache
|
||||
|
||||
temporaryPost LocalCacheTemporaryPostStore
|
||||
temporaryPostCache cache.Cache
|
||||
}
|
||||
|
||||
func NewLocalCacheLayer(baseStore store.Store, metrics einterfaces.MetricsInterface, cluster einterfaces.ClusterInterface, cacheProvider cache.Provider, logger mlog.LoggerIFace) (localCacheStore LocalCacheStore, err error) {
|
||||
|
|
@ -396,6 +409,34 @@ func NewLocalCacheLayer(baseStore store.Store, metrics einterfaces.MetricsInterf
|
|||
}
|
||||
localCacheStore.contentFlagging = LocalCacheContentFlaggingStore{ContentFlaggingStore: baseStore.ContentFlagging(), rootStore: &localCacheStore}
|
||||
|
||||
// Read Receipts
|
||||
if localCacheStore.readReceiptCache, err = cacheProvider.NewCache(&cache.CacheOptions{
|
||||
Size: ReadReceiptCacheSize,
|
||||
Name: "ReadReceipt",
|
||||
InvalidateClusterEvent: model.ClusterEventInvalidateCacheForReadReceipts,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
if localCacheStore.readReceiptPostReadersCache, err = cacheProvider.NewCache(&cache.CacheOptions{
|
||||
Size: ReadReceiptCacheSize,
|
||||
Name: "ReadReceiptPostReaders",
|
||||
InvalidateClusterEvent: model.ClusterEventInvalidateCacheForReadReceipts,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
localCacheStore.readReceipt = LocalCacheReadReceiptStore{ReadReceiptStore: baseStore.ReadReceipt(), rootStore: &localCacheStore}
|
||||
|
||||
// Temporary Posts
|
||||
if localCacheStore.temporaryPostCache, err = cacheProvider.NewCache(&cache.CacheOptions{
|
||||
Size: TemporaryPostCacheSize,
|
||||
Name: "TemporaryPost",
|
||||
DefaultExpiry: TemporaryPostCacheMinutes * time.Minute,
|
||||
InvalidateClusterEvent: model.ClusterEventInvalidateCacheForTemporaryPosts,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
localCacheStore.temporaryPost = LocalCacheTemporaryPostStore{TemporaryPostStore: baseStore.TemporaryPost(), rootStore: &localCacheStore}
|
||||
|
||||
if cluster != nil {
|
||||
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForReactions, localCacheStore.reaction.handleClusterInvalidateReaction)
|
||||
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForRoles, localCacheStore.role.handleClusterInvalidateRole)
|
||||
|
|
@ -422,6 +463,8 @@ func NewLocalCacheLayer(baseStore store.Store, metrics einterfaces.MetricsInterf
|
|||
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForTeams, localCacheStore.team.handleClusterInvalidateTeam)
|
||||
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForUserAutoTranslation, localCacheStore.autotranslation.handleClusterInvalidateUserAutoTranslation)
|
||||
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForContentFlagging, localCacheStore.contentFlagging.handleClusterInvalidateContentFlagging)
|
||||
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForReadReceipts, localCacheStore.readReceipt.handleClusterInvalidateReadReceipts)
|
||||
cluster.RegisterClusterMessageHandler(model.ClusterEventInvalidateCacheForTemporaryPosts, localCacheStore.temporaryPost.handleClusterInvalidateTemporaryPosts)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -478,6 +521,14 @@ func (s LocalCacheStore) ContentFlagging() store.ContentFlaggingStore {
|
|||
return s.contentFlagging
|
||||
}
|
||||
|
||||
func (s LocalCacheStore) ReadReceipt() store.ReadReceiptStore {
|
||||
return s.readReceipt
|
||||
}
|
||||
|
||||
func (s LocalCacheStore) TemporaryPost() store.TemporaryPostStore {
|
||||
return s.temporaryPost
|
||||
}
|
||||
|
||||
func (s LocalCacheStore) DropAllTables() {
|
||||
s.Invalidate()
|
||||
s.Store.DropAllTables()
|
||||
|
|
@ -612,6 +663,9 @@ func (s *LocalCacheStore) Invalidate() {
|
|||
s.doClearCacheCluster(s.teamAllTeamIdsForUserCache)
|
||||
s.doClearCacheCluster(s.rolePermissionsCache)
|
||||
s.doClearCacheCluster(s.userAutoTranslationCache)
|
||||
s.doClearCacheCluster(s.readReceiptCache)
|
||||
s.doClearCacheCluster(s.readReceiptPostReadersCache)
|
||||
s.doClearCacheCluster(s.temporaryPostCache)
|
||||
}
|
||||
|
||||
// allocateCacheTargets is used to fill target value types
|
||||
|
|
|
|||
|
|
@ -194,6 +194,12 @@ func getMockStore(t *testing.T) *mocks.Store {
|
|||
mockContentFlaggingStore := mocks.ContentFlaggingStore{}
|
||||
mockStore.On("ContentFlagging").Return(&mockContentFlaggingStore)
|
||||
|
||||
mockReadReceiptStore := &mocks.ReadReceiptStore{}
|
||||
mockStore.On("ReadReceipt").Return(mockReadReceiptStore)
|
||||
|
||||
mockTemporaryPostStore := mocks.TemporaryPostStore{}
|
||||
mockStore.On("TemporaryPost").Return(&mockTemporaryPostStore)
|
||||
|
||||
return &mockStore
|
||||
}
|
||||
|
||||
|
|
|
|||
165
server/channels/store/localcachelayer/read_receipt_layer.go
Normal file
165
server/channels/store/localcachelayer/read_receipt_layer.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package localcachelayer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
|
||||
)
|
||||
|
||||
type LocalCacheReadReceiptStore struct {
|
||||
store.ReadReceiptStore
|
||||
rootStore *LocalCacheStore
|
||||
}
|
||||
|
||||
func (s *LocalCacheReadReceiptStore) handleClusterInvalidateReadReceipts(msg *model.ClusterMessage) {
|
||||
if err := s.rootStore.readReceiptCache.Purge(); err != nil {
|
||||
s.rootStore.logger.Error("failed to purge read receipt cache", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s LocalCacheReadReceiptStore) ClearCaches() {
|
||||
if err := s.rootStore.readReceiptCache.Purge(); err != nil {
|
||||
s.rootStore.logger.Error("failed to purge read receipt cache", mlog.Err(err))
|
||||
}
|
||||
if err := s.rootStore.readReceiptPostReadersCache.Purge(); err != nil {
|
||||
s.rootStore.logger.Error("failed to purge read receipt post readers cache", mlog.Err(err))
|
||||
}
|
||||
|
||||
if s.rootStore.metrics != nil {
|
||||
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.readReceiptCache.Name())
|
||||
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.readReceiptPostReadersCache.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (s LocalCacheReadReceiptStore) InvalidateReadReceiptForPostsCache(postID string) {
|
||||
s.rootStore.doInvalidateCacheCluster(s.rootStore.readReceiptPostReadersCache, postID, nil)
|
||||
if s.rootStore.metrics != nil {
|
||||
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.readReceiptPostReadersCache.Name())
|
||||
}
|
||||
if externalCache, ok := s.rootStore.readReceiptCache.(cache.ExternalCache); ok {
|
||||
// For redis, invalidate all keys with pattern "postID:*"
|
||||
s.rootStore.doInvalidateCacheCluster(externalCache, fmt.Sprintf("%s:*", postID), nil)
|
||||
} else {
|
||||
if err := s.rootStore.readReceiptCache.Purge(); err != nil {
|
||||
s.rootStore.logger.Error("failed to purge read receipt cache", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.rootStore.metrics != nil {
|
||||
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.readReceiptCache.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (s LocalCacheReadReceiptStore) Delete(rctx request.CTX, postID, userID string) error {
|
||||
defer func() {
|
||||
s.InvalidateReadReceiptForPostsCache(postID)
|
||||
}()
|
||||
return s.ReadReceiptStore.Delete(rctx, postID, userID)
|
||||
}
|
||||
|
||||
func (s LocalCacheReadReceiptStore) DeleteByPost(rctx request.CTX, postID string) error {
|
||||
defer func(postID string) {
|
||||
s.InvalidateReadReceiptForPostsCache(postID)
|
||||
}(postID)
|
||||
|
||||
return s.ReadReceiptStore.DeleteByPost(rctx, postID)
|
||||
}
|
||||
|
||||
func (s LocalCacheReadReceiptStore) Save(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
defer func() {
|
||||
s.rootStore.doInvalidateCacheCluster(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", receipt.PostID, receipt.UserID), nil)
|
||||
s.rootStore.doInvalidateCacheCluster(s.rootStore.readReceiptPostReadersCache, receipt.PostID, nil)
|
||||
}()
|
||||
return s.ReadReceiptStore.Save(rctx, receipt)
|
||||
}
|
||||
|
||||
func (s LocalCacheReadReceiptStore) Update(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
defer func() {
|
||||
s.rootStore.doInvalidateCacheCluster(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", receipt.PostID, receipt.UserID), nil)
|
||||
}()
|
||||
return s.ReadReceiptStore.Update(rctx, receipt)
|
||||
}
|
||||
|
||||
func (s LocalCacheReadReceiptStore) Get(rctx request.CTX, postID, userID string) (*model.ReadReceipt, error) {
|
||||
// no need to store the entire struct in cache, just the expireAt would be sufficient
|
||||
// as other two fields are part of the cache key
|
||||
var expireAt int64
|
||||
if err := s.rootStore.doStandardReadCache(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", postID, userID), &expireAt); err == nil {
|
||||
return &model.ReadReceipt{
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
ExpireAt: expireAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
rr, err := s.ReadReceiptStore.Get(rctx, postID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", postID, userID), rr.ExpireAt)
|
||||
|
||||
// Update post readers cache: add this userID to the list if not already present
|
||||
var existingUserIDs []string
|
||||
if err := s.rootStore.doStandardReadCache(s.rootStore.readReceiptPostReadersCache, postID, &existingUserIDs); err == nil {
|
||||
// Cache exists: check if userID is already in the list
|
||||
if !slices.Contains(existingUserIDs, userID) {
|
||||
// Add userID to the existing list
|
||||
existingUserIDs = append(existingUserIDs, userID)
|
||||
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptPostReadersCache, postID, existingUserIDs)
|
||||
}
|
||||
} else {
|
||||
// Cache doesn't exist: create new entry with just this userID
|
||||
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptPostReadersCache, postID, []string{userID})
|
||||
}
|
||||
|
||||
return rr, nil
|
||||
}
|
||||
|
||||
func (s LocalCacheReadReceiptStore) GetByPost(rctx request.CTX, postID string) ([]*model.ReadReceipt, error) {
|
||||
// Try to get cached user IDs for this post
|
||||
var cachedUserIDs []string
|
||||
if err := s.rootStore.doStandardReadCache(s.rootStore.readReceiptPostReadersCache, postID, &cachedUserIDs); err == nil {
|
||||
// Cache hit: reconstruct receipts from cached user IDs and individual receipt caches
|
||||
receipts := make([]*model.ReadReceipt, 0, len(cachedUserIDs))
|
||||
for _, userID := range cachedUserIDs {
|
||||
var expireAt int64
|
||||
if err := s.rootStore.doStandardReadCache(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", postID, userID), &expireAt); err == nil {
|
||||
receipts = append(receipts, &model.ReadReceipt{
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
ExpireAt: expireAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
// If we got all receipts from cache, return them
|
||||
if len(receipts) == len(cachedUserIDs) {
|
||||
return receipts, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or partial cache: fetch from underlying store
|
||||
receipts, err := s.ReadReceiptStore.GetByPost(rctx, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the user IDs for this post
|
||||
userIDs := make([]string, len(receipts))
|
||||
for i, receipt := range receipts {
|
||||
userIDs[i] = receipt.UserID
|
||||
// Also ensure individual receipts are cached
|
||||
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptCache, fmt.Sprintf("%s:%s", postID, receipt.UserID), receipt.ExpireAt)
|
||||
}
|
||||
s.rootStore.doStandardAddToCache(s.rootStore.readReceiptPostReadersCache, postID, userIDs)
|
||||
|
||||
return receipts, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package localcachelayer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
|
||||
)
|
||||
|
||||
func TestReadReceiptStore(t *testing.T) {
|
||||
StoreTestWithSqlStore(t, storetest.TestReadReceiptStore)
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package localcachelayer
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
type LocalCacheTemporaryPostStore struct {
|
||||
store.TemporaryPostStore
|
||||
rootStore *LocalCacheStore
|
||||
}
|
||||
|
||||
func (s *LocalCacheTemporaryPostStore) handleClusterInvalidateTemporaryPosts(msg *model.ClusterMessage) {
|
||||
if err := s.rootStore.temporaryPostCache.Purge(); err != nil {
|
||||
s.rootStore.logger.Error("failed to purge temporary post cache", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s LocalCacheTemporaryPostStore) ClearCaches() {
|
||||
if err := s.rootStore.temporaryPostCache.Purge(); err != nil {
|
||||
s.rootStore.logger.Error("failed to purge temporary post cache", mlog.Err(err))
|
||||
}
|
||||
|
||||
if s.rootStore.metrics != nil {
|
||||
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.temporaryPostCache.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (s LocalCacheTemporaryPostStore) InvalidateTemporaryPost(id string) {
|
||||
s.rootStore.doInvalidateCacheCluster(s.rootStore.temporaryPostCache, id, nil)
|
||||
if s.rootStore.metrics != nil {
|
||||
s.rootStore.metrics.IncrementMemCacheInvalidationCounter(s.rootStore.temporaryPostCache.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (s LocalCacheTemporaryPostStore) Get(rctx request.CTX, id string) (*model.TemporaryPost, error) {
|
||||
var post *model.TemporaryPost
|
||||
if err := s.rootStore.doStandardReadCache(s.rootStore.temporaryPostCache, id, &post); err == nil {
|
||||
return post, nil
|
||||
}
|
||||
|
||||
post, err := s.TemporaryPostStore.Get(rctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.rootStore.doStandardAddToCache(s.rootStore.temporaryPostCache, id, post)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s LocalCacheTemporaryPostStore) Delete(rctx request.CTX, id string) error {
|
||||
defer s.InvalidateTemporaryPost(id)
|
||||
return s.TemporaryPostStore.Delete(rctx, id)
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ type RetryLayer struct {
|
|||
PropertyGroupStore store.PropertyGroupStore
|
||||
PropertyValueStore store.PropertyValueStore
|
||||
ReactionStore store.ReactionStore
|
||||
ReadReceiptStore store.ReadReceiptStore
|
||||
RemoteClusterStore store.RemoteClusterStore
|
||||
RetentionPolicyStore store.RetentionPolicyStore
|
||||
RoleStore store.RoleStore
|
||||
|
|
@ -65,6 +66,7 @@ type RetryLayer struct {
|
|||
StatusStore store.StatusStore
|
||||
SystemStore store.SystemStore
|
||||
TeamStore store.TeamStore
|
||||
TemporaryPostStore store.TemporaryPostStore
|
||||
TermsOfServiceStore store.TermsOfServiceStore
|
||||
ThreadStore store.ThreadStore
|
||||
TokenStore store.TokenStore
|
||||
|
|
@ -215,6 +217,10 @@ func (s *RetryLayer) Reaction() store.ReactionStore {
|
|||
return s.ReactionStore
|
||||
}
|
||||
|
||||
func (s *RetryLayer) ReadReceipt() store.ReadReceiptStore {
|
||||
return s.ReadReceiptStore
|
||||
}
|
||||
|
||||
func (s *RetryLayer) RemoteCluster() store.RemoteClusterStore {
|
||||
return s.RemoteClusterStore
|
||||
}
|
||||
|
|
@ -255,6 +261,10 @@ func (s *RetryLayer) Team() store.TeamStore {
|
|||
return s.TeamStore
|
||||
}
|
||||
|
||||
func (s *RetryLayer) TemporaryPost() store.TemporaryPostStore {
|
||||
return s.TemporaryPostStore
|
||||
}
|
||||
|
||||
func (s *RetryLayer) TermsOfService() store.TermsOfServiceStore {
|
||||
return s.TermsOfServiceStore
|
||||
}
|
||||
|
|
@ -462,6 +472,11 @@ type RetryLayerReactionStore struct {
|
|||
Root *RetryLayer
|
||||
}
|
||||
|
||||
type RetryLayerReadReceiptStore struct {
|
||||
store.ReadReceiptStore
|
||||
Root *RetryLayer
|
||||
}
|
||||
|
||||
type RetryLayerRemoteClusterStore struct {
|
||||
store.RemoteClusterStore
|
||||
Root *RetryLayer
|
||||
|
|
@ -512,6 +527,11 @@ type RetryLayerTeamStore struct {
|
|||
Root *RetryLayer
|
||||
}
|
||||
|
||||
type RetryLayerTemporaryPostStore struct {
|
||||
store.TemporaryPostStore
|
||||
Root *RetryLayer
|
||||
}
|
||||
|
||||
type RetryLayerTermsOfServiceStore struct {
|
||||
store.TermsOfServiceStore
|
||||
Root *RetryLayer
|
||||
|
|
@ -8354,6 +8374,27 @@ func (s *RetryLayerPostStore) GetPostsCreatedAt(channelID string, timestamp int6
|
|||
|
||||
}
|
||||
|
||||
func (s *RetryLayerPostStore) GetPostsForReporting(rctx request.CTX, queryParams model.ReportPostQueryParams) (*model.ReportPostListResponse, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.PostStore.GetPostsForReporting(rctx, queryParams)
|
||||
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 *RetryLayerPostStore) GetPostsSince(rctx request.CTX, options model.GetPostsSinceOptions, allowFromCache bool, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
||||
|
||||
tries := 0
|
||||
|
|
@ -10250,6 +10291,180 @@ func (s *RetryLayerReactionStore) Save(reaction *model.Reaction) (*model.Reactio
|
|||
|
||||
}
|
||||
|
||||
func (s *RetryLayerReadReceiptStore) Delete(rctx request.CTX, postID string, userID string) error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.ReadReceiptStore.Delete(rctx, postID, userID)
|
||||
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 *RetryLayerReadReceiptStore) DeleteByPost(rctx request.CTX, postID string) error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.ReadReceiptStore.DeleteByPost(rctx, postID)
|
||||
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 *RetryLayerReadReceiptStore) Get(rctx request.CTX, postID string, userID string) (*model.ReadReceipt, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.ReadReceiptStore.Get(rctx, postID, userID)
|
||||
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 *RetryLayerReadReceiptStore) GetByPost(rctx request.CTX, postID string) ([]*model.ReadReceipt, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.ReadReceiptStore.GetByPost(rctx, postID)
|
||||
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 *RetryLayerReadReceiptStore) GetReadCountForPost(rctx request.CTX, postID string) (int64, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.ReadReceiptStore.GetReadCountForPost(rctx, postID)
|
||||
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 *RetryLayerReadReceiptStore) GetUnreadCountForPost(rctx request.CTX, post *model.Post) (int64, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.ReadReceiptStore.GetUnreadCountForPost(rctx, post)
|
||||
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 *RetryLayerReadReceiptStore) InvalidateReadReceiptForPostsCache(postID string) {
|
||||
|
||||
s.ReadReceiptStore.InvalidateReadReceiptForPostsCache(postID)
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerReadReceiptStore) Save(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.ReadReceiptStore.Save(rctx, receipt)
|
||||
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 *RetryLayerReadReceiptStore) Update(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.ReadReceiptStore.Update(rctx, receipt)
|
||||
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 *RetryLayerRemoteClusterStore) Delete(remoteClusterID string) (bool, error) {
|
||||
|
||||
tries := 0
|
||||
|
|
@ -12608,6 +12823,48 @@ func (s *RetryLayerSystemStore) GetByName(name string) (*model.System, error) {
|
|||
|
||||
}
|
||||
|
||||
func (s *RetryLayerSystemStore) GetByNameWithContext(rctx request.CTX, name string) (*model.System, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.SystemStore.GetByNameWithContext(rctx, name)
|
||||
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 *RetryLayerSystemStore) GetWithContext(rctx request.CTX) (model.StringMap, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.SystemStore.GetWithContext(rctx)
|
||||
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 *RetryLayerSystemStore) InsertIfExists(system *model.System) (*model.System, error) {
|
||||
|
||||
tries := 0
|
||||
|
|
@ -13775,6 +14032,96 @@ func (s *RetryLayerTeamStore) UserBelongsToTeams(userID string, teamIds []string
|
|||
|
||||
}
|
||||
|
||||
func (s *RetryLayerTemporaryPostStore) Delete(rctx request.CTX, id string) error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.TemporaryPostStore.Delete(rctx, id)
|
||||
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 *RetryLayerTemporaryPostStore) Get(rctx request.CTX, id string) (*model.TemporaryPost, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.TemporaryPostStore.Get(rctx, id)
|
||||
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 *RetryLayerTemporaryPostStore) GetExpiredPosts(rctx request.CTX) ([]string, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.TemporaryPostStore.GetExpiredPosts(rctx)
|
||||
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 *RetryLayerTemporaryPostStore) InvalidateTemporaryPost(id string) {
|
||||
|
||||
s.TemporaryPostStore.InvalidateTemporaryPost(id)
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerTemporaryPostStore) Save(rctx request.CTX, post *model.TemporaryPost) (*model.TemporaryPost, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.TemporaryPostStore.Save(rctx, post)
|
||||
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 *RetryLayerTermsOfServiceStore) Get(id string, allowFromCache bool) (*model.TermsOfService, error) {
|
||||
|
||||
tries := 0
|
||||
|
|
@ -17246,6 +17593,7 @@ func New(childStore store.Store) *RetryLayer {
|
|||
newStore.PropertyGroupStore = &RetryLayerPropertyGroupStore{PropertyGroupStore: childStore.PropertyGroup(), Root: &newStore}
|
||||
newStore.PropertyValueStore = &RetryLayerPropertyValueStore{PropertyValueStore: childStore.PropertyValue(), Root: &newStore}
|
||||
newStore.ReactionStore = &RetryLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore}
|
||||
newStore.ReadReceiptStore = &RetryLayerReadReceiptStore{ReadReceiptStore: childStore.ReadReceipt(), Root: &newStore}
|
||||
newStore.RemoteClusterStore = &RetryLayerRemoteClusterStore{RemoteClusterStore: childStore.RemoteCluster(), Root: &newStore}
|
||||
newStore.RetentionPolicyStore = &RetryLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore}
|
||||
newStore.RoleStore = &RetryLayerRoleStore{RoleStore: childStore.Role(), Root: &newStore}
|
||||
|
|
@ -17256,6 +17604,7 @@ func New(childStore store.Store) *RetryLayer {
|
|||
newStore.StatusStore = &RetryLayerStatusStore{StatusStore: childStore.Status(), Root: &newStore}
|
||||
newStore.SystemStore = &RetryLayerSystemStore{SystemStore: childStore.System(), Root: &newStore}
|
||||
newStore.TeamStore = &RetryLayerTeamStore{TeamStore: childStore.Team(), Root: &newStore}
|
||||
newStore.TemporaryPostStore = &RetryLayerTemporaryPostStore{TemporaryPostStore: childStore.TemporaryPost(), Root: &newStore}
|
||||
newStore.TermsOfServiceStore = &RetryLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore}
|
||||
newStore.ThreadStore = &RetryLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore}
|
||||
newStore.TokenStore = &RetryLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ func genStore() *mocks.Store {
|
|||
mock.On("Attributes").Return(&mocks.AttributesStore{})
|
||||
mock.On("AutoTranslation").Return(&mocks.AutoTranslationStore{})
|
||||
mock.On("ContentFlagging").Return(&mocks.ContentFlaggingStore{})
|
||||
mock.On("ReadReceipt").Return(&mocks.ReadReceiptStore{})
|
||||
mock.On("TemporaryPost").Return(&mocks.TemporaryPostStore{})
|
||||
return mock
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ func (s SearchPostStore) indexPost(rctx request.CTX, post *model.Post) {
|
|||
for _, engine := range s.rootStore.searchEngine.GetActiveEngines() {
|
||||
if engine.IsIndexingEnabled() {
|
||||
runIndexFn(rctx, engine, func(engineCopy searchengine.SearchEngineInterface) {
|
||||
if post.Type == model.PostTypeBurnOnRead {
|
||||
return
|
||||
}
|
||||
channel, chanErr := s.rootStore.Channel().Get(post.ChannelId, true)
|
||||
if chanErr != nil {
|
||||
rctx.Logger().Error("Couldn't get channel for post for SearchEngine indexing.", mlog.String("channel_id", post.ChannelId), mlog.String("search_engine", engineCopy.GetName()), mlog.String("post_id", post.Id), mlog.Err(chanErr))
|
||||
|
|
|
|||
|
|
@ -3082,11 +3082,12 @@ func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, inc
|
|||
OrderBy("c.DisplayName").
|
||||
Limit(model.ChannelSearchDefaultLimit)
|
||||
|
||||
// Always filter out soft-deleted team memberships - users removed from
|
||||
// a team should not see channels from that team regardless of includeDeleted
|
||||
query = query.Where(sq.Eq{"tm.DeleteAt": 0})
|
||||
|
||||
if !includeDeleted {
|
||||
query = query.Where(sq.And{
|
||||
sq.Eq{"c.DeleteAt": 0},
|
||||
sq.Eq{"tm.DeleteAt": 0},
|
||||
})
|
||||
query = query.Where(sq.Eq{"c.DeleteAt": 0})
|
||||
}
|
||||
|
||||
if isGuest {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ func draftSliceColumns() []string {
|
|||
"FileIds",
|
||||
"Props",
|
||||
"Priority",
|
||||
"Type",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ func draftToSlice(draft *model.Draft) []any {
|
|||
model.ArrayToJSON(draft.FileIds),
|
||||
model.StringInterfaceToJSON(draft.Props),
|
||||
model.StringInterfaceToJSON(draft.Priority),
|
||||
draft.Type,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +100,7 @@ func (s *SqlDraftStore) Upsert(draft *model.Draft) (*model.Draft, error) {
|
|||
builder := s.getQueryBuilder().Insert("Drafts").
|
||||
Columns(draftSliceColumns()...).
|
||||
Values(draftToSlice(draft)...).
|
||||
SuffixExpr(sq.Expr("ON CONFLICT (UserId, ChannelId, RootId) DO UPDATE SET UpdateAt = ?, Message = ?, Props = ?, FileIds = ?, Priority = ?, DeleteAt = ?", draft.UpdateAt, draft.Message, draft.Props, draft.FileIds, draft.Priority, 0))
|
||||
SuffixExpr(sq.Expr("ON CONFLICT (UserId, ChannelId, RootId) DO UPDATE SET UpdateAt = ?, Message = ?, Props = ?, FileIds = ?, Priority = ?, Type = ?, DeleteAt = ?", draft.UpdateAt, draft.Message, draft.Props, draft.FileIds, draft.Priority, draft.Type, 0))
|
||||
|
||||
query, args, err := builder.ToSql()
|
||||
|
||||
|
|
@ -127,6 +129,7 @@ func (s *SqlDraftStore) GetDraftsForUser(userID, teamID string) ([]*model.Draft,
|
|||
"Drafts.FileIds",
|
||||
"Drafts.Props",
|
||||
"Drafts.Priority",
|
||||
"Drafts.Type",
|
||||
).
|
||||
From("Drafts").
|
||||
InnerJoin("ChannelMembers ON ChannelMembers.ChannelId = Drafts.ChannelId").
|
||||
|
|
|
|||
|
|
@ -548,6 +548,7 @@ func (fs SqlFileInfoStore) Search(rctx request.CTX, paramsList []*model.SearchPa
|
|||
sq.Eq{"FileInfo.CreatorId": model.BookmarkFileOwner},
|
||||
sq.NotEq{"FileInfo.PostId": ""},
|
||||
}).
|
||||
Where(sq.Expr("NOT EXISTS (SELECT 1 FROM TemporaryPosts WHERE TemporaryPosts.PostId = FileInfo.PostId)")).
|
||||
OrderBy("FileInfo.CreateAt DESC").
|
||||
Limit(100)
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*m
|
|||
maxDateNewRootPosts := make(map[string]int64)
|
||||
rootIds := make(map[string]int)
|
||||
maxDateRootIds := make(map[string]int64)
|
||||
burnOnReadPosts := make(map[string]*model.TemporaryPost)
|
||||
for idx, post := range posts {
|
||||
if post.Id != "" && !post.IsRemote() {
|
||||
return nil, idx, store.NewErrInvalidInput("Post", "id", post.Id)
|
||||
|
|
@ -164,6 +165,29 @@ func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*m
|
|||
}
|
||||
post.ValidateProps(rctx.Logger())
|
||||
|
||||
if post.Type == model.PostTypeBurnOnRead {
|
||||
expireAt := post.GetProp(model.PostPropsExpireAt)
|
||||
if expireAt == "" {
|
||||
return nil, idx, errors.New("expire_at is required for burn on read posts")
|
||||
}
|
||||
|
||||
if post.RootId != "" {
|
||||
return nil, idx, errors.New("burn on read posts cannot have a root_id")
|
||||
}
|
||||
|
||||
expireAtInt, ok := expireAt.(int64)
|
||||
if !ok {
|
||||
return nil, idx, fmt.Errorf("expire_at is not a valid int64: %s", expireAt)
|
||||
}
|
||||
|
||||
burnOnReadPost, _, err := model.CreateTemporaryPost(post, expireAtInt)
|
||||
if err != nil {
|
||||
return nil, idx, errors.Wrap(err, "failed to create burn on read post")
|
||||
}
|
||||
burnOnReadPosts[post.Id] = burnOnReadPost
|
||||
continue
|
||||
}
|
||||
|
||||
if currentChannelCount, ok := channelNewPosts[post.ChannelId]; !ok {
|
||||
if post.IsJoinLeaveMessage() {
|
||||
channelNewPosts[post.ChannelId] = 0
|
||||
|
|
@ -241,6 +265,19 @@ func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*m
|
|||
return nil, -1, errors.Wrap(err, "failed to save posts persistent notifications")
|
||||
}
|
||||
|
||||
for _, post := range burnOnReadPosts {
|
||||
tmpStore := s.SqlStore.TemporaryPost()
|
||||
tps, ok := tmpStore.(*SqlTemporaryPostStore)
|
||||
if !ok {
|
||||
return nil, -1, errors.New("temporary post store is not a SqlTemporaryPostStore")
|
||||
}
|
||||
|
||||
_, err = tps.saveT(transaction, post)
|
||||
if err != nil {
|
||||
return nil, -1, errors.Wrap(err, "failed to save burn on read post")
|
||||
}
|
||||
}
|
||||
|
||||
if err = transaction.Commit(); err != nil {
|
||||
// don't need to rollback here since the transaction is already closed
|
||||
return posts, -1, errors.Wrap(err, "commit_transaction")
|
||||
|
|
@ -866,6 +903,10 @@ func (s *SqlPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptio
|
|||
}
|
||||
|
||||
for _, p := range posts {
|
||||
if p.Type == model.PostTypeBurnOnRead {
|
||||
pl.BurnOnReadPosts[p.Id] = p
|
||||
}
|
||||
|
||||
if p.Id == id {
|
||||
// Based on the conditions above such as sq.Or{ sq.Eq{"p.Id": rootId}, sq.Eq{"p.RootId": rootId}, }
|
||||
// posts may contain the "id" post which has already been fetched and added in the "pl"
|
||||
|
|
@ -1016,6 +1057,14 @@ func (s *SqlPostStore) permanentDelete(postIds []string) (err error) {
|
|||
return err
|
||||
}
|
||||
|
||||
if err = s.permanentDeleteTemporaryPosts(transaction, postIds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.permanentDeleteReadReceipts(transaction, postIds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Delete("Posts").
|
||||
Where(
|
||||
|
|
@ -1072,6 +1121,14 @@ func (s *SqlPostStore) permanentDeleteAllCommentByUser(userId string) (err error
|
|||
return err
|
||||
}
|
||||
|
||||
if err = s.permanentDeleteTemporaryPosts(transaction, postIds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.permanentDeleteReadReceipts(transaction, postIds); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = transaction.Commit(); err != nil {
|
||||
return errors.Wrap(err, "commit_transaction")
|
||||
}
|
||||
|
|
@ -1153,6 +1210,14 @@ func (s *SqlPostStore) PermanentDeleteByChannel(rctx request.CTX, channelId stri
|
|||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
if err = s.permanentDeleteTemporaryPosts(transaction, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.permanentDeleteReadReceipts(transaction, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Delete("Posts").
|
||||
Where(
|
||||
|
|
@ -2259,6 +2324,10 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search
|
|||
// Don't return the error to the caller as it is of no use to the user. Instead return an empty set of search results.
|
||||
} else {
|
||||
for _, p := range posts {
|
||||
// exclude burn on read posts from search results
|
||||
if p.Type == model.PostTypeBurnOnRead {
|
||||
continue
|
||||
}
|
||||
if searchType == "Hashtags" {
|
||||
exactMatch := false
|
||||
for tag := range strings.SplitSeq(p.Hashtags, " ") {
|
||||
|
|
@ -3000,6 +3069,30 @@ func (s *SqlPostStore) permanentDeleteReactions(transaction *sqlxTxWrapper, post
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlPostStore) permanentDeleteReadReceipts(transaction *sqlxTxWrapper, postIds []string) error {
|
||||
query := s.getQueryBuilder().
|
||||
Delete("ReadReceipts").
|
||||
Where(
|
||||
sq.Eq{"PostId": postIds},
|
||||
)
|
||||
if _, err := transaction.ExecBuilder(query); err != nil {
|
||||
return errors.Wrap(err, "failed to delete ReadReceipts")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlPostStore) permanentDeleteTemporaryPosts(transaction *sqlxTxWrapper, postIds []string) error {
|
||||
query := s.getQueryBuilder().
|
||||
Delete("TemporaryPosts").
|
||||
Where(
|
||||
sq.Eq{"PostId": postIds},
|
||||
)
|
||||
if _, err := transaction.ExecBuilder(query); err != nil {
|
||||
return errors.Wrap(err, "failed to delete Threads")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteThread marks a thread as deleted at the given time.
|
||||
func (s *SqlPostStore) deleteThread(transaction *sqlxTxWrapper, postId string, deleteAtTime int64) error {
|
||||
queryString, args, err := s.getQueryBuilder().
|
||||
|
|
|
|||
165
server/channels/store/sqlstore/read_receipt_store.go
Normal file
165
server/channels/store/sqlstore/read_receipt_store.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
||||
|
||||
sq "github.com/mattermost/squirrel"
|
||||
)
|
||||
|
||||
type SqlReadReceiptStore struct {
|
||||
*SqlStore
|
||||
metrics einterfaces.MetricsInterface
|
||||
|
||||
selectQueryBuilder sq.SelectBuilder
|
||||
}
|
||||
|
||||
func newSqlReadReceiptStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.ReadReceiptStore {
|
||||
s := &SqlReadReceiptStore{
|
||||
SqlStore: sqlStore,
|
||||
metrics: metrics,
|
||||
}
|
||||
|
||||
s.selectQueryBuilder = s.getQueryBuilder().Select(readReceiptSliceColumns()...).From("ReadReceipts")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func readReceiptSliceColumns() []string {
|
||||
return []string{
|
||||
"PostId",
|
||||
"UserId",
|
||||
"ExpireAt",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SqlReadReceiptStore) InvalidateReadReceiptForPostsCache(postID string) {
|
||||
}
|
||||
|
||||
func (s *SqlReadReceiptStore) Save(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Insert("ReadReceipts").
|
||||
Columns(readReceiptSliceColumns()...).
|
||||
Values(
|
||||
receipt.PostID,
|
||||
receipt.UserID,
|
||||
receipt.ExpireAt,
|
||||
)
|
||||
|
||||
_, err := s.GetMaster().ExecBuilder(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func (s *SqlReadReceiptStore) Update(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Update("ReadReceipts").
|
||||
Set("ExpireAt", receipt.ExpireAt).
|
||||
Where(sq.Eq{"PostId": receipt.PostID, "UserId": receipt.UserID})
|
||||
|
||||
_, err := s.GetMaster().ExecBuilder(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func (s *SqlReadReceiptStore) Delete(rctx request.CTX, postID, userID string) error {
|
||||
query := s.getQueryBuilder().
|
||||
Delete("ReadReceipts").
|
||||
Where(sq.Eq{"PostId": postID, "UserId": userID})
|
||||
|
||||
_, err := s.GetMaster().ExecBuilder(query)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SqlReadReceiptStore) DeleteByPost(rctx request.CTX, postID string) error {
|
||||
query := s.getQueryBuilder().
|
||||
Delete("ReadReceipts").
|
||||
Where(sq.Eq{"PostId": postID})
|
||||
|
||||
_, err := s.GetMaster().ExecBuilder(query)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SqlReadReceiptStore) Get(rctx request.CTX, postID, userID string) (*model.ReadReceipt, error) {
|
||||
query := s.selectQueryBuilder.
|
||||
Where(sq.Eq{"PostId": postID, "UserId": userID})
|
||||
|
||||
var receipt model.ReadReceipt
|
||||
err := s.GetReplica().GetBuilder(&receipt, query)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, store.NewErrNotFound("ReadReceipt", postID+"_"+userID)
|
||||
}
|
||||
|
||||
return nil, errors.Wrapf(err, "failed to get ReadReceipt with id=%s", postID+"_"+userID)
|
||||
}
|
||||
|
||||
return &receipt, nil
|
||||
}
|
||||
|
||||
func (s *SqlReadReceiptStore) GetByPost(rctx request.CTX, postID string) ([]*model.ReadReceipt, error) {
|
||||
query := s.selectQueryBuilder.
|
||||
Where(sq.Eq{"PostId": postID})
|
||||
|
||||
var receipts []*model.ReadReceipt
|
||||
err := s.GetReplica().SelectBuilder(&receipts, query)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to get ReadReceipts for postId=%s", postID)
|
||||
}
|
||||
|
||||
return receipts, nil
|
||||
}
|
||||
|
||||
func (s *SqlReadReceiptStore) GetReadCountForPost(rctx request.CTX, postID string) (int64, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select("COUNT(*)").
|
||||
From("ReadReceipts").
|
||||
Where(sq.Eq{"PostId": postID})
|
||||
|
||||
var count int64
|
||||
err := s.GetReplica().GetBuilder(&count, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *SqlReadReceiptStore) GetUnreadCountForPost(rctx request.CTX, post *model.Post) (int64, error) {
|
||||
// Count channel members who haven't read the post (excluding post author)
|
||||
// LEFT JOIN with ReadReceipts to find members without a read receipt for this post
|
||||
unreadQuery := s.getQueryBuilder().
|
||||
Select("COUNT(*)").
|
||||
From("ChannelMembers").
|
||||
LeftJoin("ReadReceipts ON ChannelMembers.UserId = ReadReceipts.UserId AND ReadReceipts.PostId = ?", post.Id).
|
||||
Where(sq.And{
|
||||
sq.Eq{"ChannelMembers.ChannelId": post.ChannelId},
|
||||
sq.NotEq{"ChannelMembers.UserId": post.UserId},
|
||||
sq.Eq{"ReadReceipts.UserId": nil},
|
||||
})
|
||||
|
||||
var unreadCount int64
|
||||
// Use master to avoid stale data from replica after writing a read receipt
|
||||
err := s.GetMaster().GetBuilder(&unreadCount, unreadQuery)
|
||||
if err != nil {
|
||||
return -1, errors.Wrapf(err, "failed to get unread count for postId=%s channelId=%s", post.Id, post.ChannelId)
|
||||
}
|
||||
|
||||
// Return true if no one is unread (all have read it)
|
||||
return unreadCount, nil
|
||||
}
|
||||
14
server/channels/store/sqlstore/read_receipt_store_test.go
Normal file
14
server/channels/store/sqlstore/read_receipt_store_test.go
Normal file
|
|
@ -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 TestReadReceiptStore(t *testing.T) {
|
||||
StoreTestWithSqlStore(t, storetest.TestReadReceiptStore)
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ func (s *SqlScheduledPostStore) columns(prefix string) []string {
|
|||
prefix + "ScheduledAt",
|
||||
prefix + "ProcessedAt",
|
||||
prefix + "ErrorCode",
|
||||
prefix + "Type",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ func (s *SqlScheduledPostStore) scheduledPostToSlice(scheduledPost *model.Schedu
|
|||
scheduledPost.ScheduledAt,
|
||||
scheduledPost.ProcessedAt,
|
||||
scheduledPost.ErrorCode,
|
||||
scheduledPost.Type,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,6 +243,7 @@ func (s *SqlScheduledPostStore) toUpdateMap(scheduledPost *model.ScheduledPost)
|
|||
"ScheduledAt": scheduledPost.ScheduledAt,
|
||||
"ProcessedAt": now,
|
||||
"ErrorCode": scheduledPost.ErrorCode,
|
||||
"Type": scheduledPost.Type,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ type SqlStoreStores struct {
|
|||
Attributes store.AttributesStore
|
||||
autotranslation store.AutoTranslationStore
|
||||
ContentFlagging store.ContentFlaggingStore
|
||||
readReceipt store.ReadReceiptStore
|
||||
temporaryPost store.TemporaryPostStore
|
||||
}
|
||||
|
||||
type SqlStore struct {
|
||||
|
|
@ -263,6 +265,8 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface
|
|||
store.stores.Attributes = newSqlAttributesStore(store, metrics)
|
||||
store.stores.autotranslation = newSqlAutoTranslationStore(store)
|
||||
store.stores.ContentFlagging = newContentFlaggingStore(store)
|
||||
store.stores.readReceipt = newSqlReadReceiptStore(store, metrics)
|
||||
store.stores.temporaryPost = newSqlTemporaryPostStore(store, metrics)
|
||||
|
||||
store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures()
|
||||
|
||||
|
|
@ -882,6 +886,14 @@ func (ss *SqlStore) AutoTranslation() store.AutoTranslationStore {
|
|||
return ss.stores.autotranslation
|
||||
}
|
||||
|
||||
func (ss *SqlStore) ReadReceipt() store.ReadReceiptStore {
|
||||
return ss.stores.readReceipt
|
||||
}
|
||||
|
||||
func (ss *SqlStore) TemporaryPost() store.TemporaryPostStore {
|
||||
return ss.stores.temporaryPost
|
||||
}
|
||||
|
||||
func (ss *SqlStore) DropAllTables() {
|
||||
ss.masterX.Exec(`DO
|
||||
$func$
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
|
|
@ -65,11 +66,15 @@ func (s SqlSystemStore) Update(system *model.System) error {
|
|||
}
|
||||
|
||||
func (s SqlSystemStore) Get() (model.StringMap, error) {
|
||||
return s.GetWithContext(request.EmptyContext(s.logger))
|
||||
}
|
||||
|
||||
func (s SqlSystemStore) GetWithContext(rctx request.CTX) (model.StringMap, error) {
|
||||
systems := []model.System{}
|
||||
props := make(model.StringMap)
|
||||
|
||||
query := s.systemSelectQuery
|
||||
if err := s.GetReplica().SelectBuilder(&systems, query); err != nil {
|
||||
if err := s.DBXFromContext(rctx.Context()).SelectBuilder(&systems, query); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get System list")
|
||||
}
|
||||
|
||||
|
|
@ -81,9 +86,13 @@ func (s SqlSystemStore) Get() (model.StringMap, error) {
|
|||
}
|
||||
|
||||
func (s SqlSystemStore) GetByName(name string) (*model.System, error) {
|
||||
return s.GetByNameWithContext(store.RequestContextWithMaster(request.EmptyContext(s.logger)), name)
|
||||
}
|
||||
|
||||
func (s SqlSystemStore) GetByNameWithContext(rctx request.CTX, name string) (*model.System, error) {
|
||||
var system model.System
|
||||
query := s.systemSelectQuery.Where(sq.Eq{"Name": name})
|
||||
if err := s.GetMaster().GetBuilder(&system, query); err != nil {
|
||||
if err := s.DBXFromContext(rctx.Context()).GetBuilder(&system, query); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, store.NewErrNotFound("System", fmt.Sprintf("name=%s", system.Name))
|
||||
}
|
||||
|
|
|
|||
163
server/channels/store/sqlstore/temporary_post_store.go
Normal file
163
server/channels/store/sqlstore/temporary_post_store.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
sq "github.com/mattermost/squirrel"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
||||
)
|
||||
|
||||
type SqlTemporaryPostStore struct {
|
||||
*SqlStore
|
||||
metrics einterfaces.MetricsInterface
|
||||
|
||||
selectQueryBuilder sq.SelectBuilder
|
||||
}
|
||||
|
||||
func newSqlTemporaryPostStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.TemporaryPostStore {
|
||||
s := &SqlTemporaryPostStore{
|
||||
SqlStore: sqlStore,
|
||||
metrics: metrics,
|
||||
}
|
||||
|
||||
s.selectQueryBuilder = s.getQueryBuilder().Select(temporaryPostSliceColumns()...).From("TemporaryPosts")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func temporaryPostSliceColumns() []string {
|
||||
return []string{
|
||||
"PostId",
|
||||
"Type",
|
||||
"ExpireAt",
|
||||
"Message",
|
||||
"FileIds",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SqlTemporaryPostStore) InvalidateTemporaryPost(id string) {
|
||||
}
|
||||
|
||||
func (s *SqlTemporaryPostStore) Save(rctx request.CTX, post *model.TemporaryPost) (_ *model.TemporaryPost, err error) {
|
||||
if err = post.IsValid(); err != nil {
|
||||
return nil, fmt.Errorf("failed to save TemporaryPost: %w", err)
|
||||
}
|
||||
|
||||
var tx *sqlxTxWrapper
|
||||
tx, err = s.GetMaster().Beginx()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer finalizeTransactionX(tx, &err)
|
||||
|
||||
_, err = s.saveT(tx, post)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *SqlTemporaryPostStore) saveT(tx *sqlxTxWrapper, post *model.TemporaryPost) (*model.TemporaryPost, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Insert("TemporaryPosts").
|
||||
Columns(temporaryPostSliceColumns()...).
|
||||
Values(
|
||||
post.ID,
|
||||
post.Type,
|
||||
post.ExpireAt,
|
||||
post.Message,
|
||||
model.ArrayToJSON(post.FileIDs),
|
||||
).SuffixExpr(sq.Expr("ON CONFLICT (PostId) DO UPDATE SET Type = ?, ExpireAt = ?, Message = ?, FileIds = ?", post.Type, post.ExpireAt, post.Message, model.ArrayToJSON(post.FileIDs)))
|
||||
|
||||
_, err := tx.ExecBuilder(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *SqlTemporaryPostStore) Get(rctx request.CTX, id string) (*model.TemporaryPost, error) {
|
||||
query := s.selectQueryBuilder.
|
||||
Where(sq.Eq{"PostId": id})
|
||||
|
||||
// Use a struct with FileIds as string for scanning
|
||||
// Map PostId column to Id field
|
||||
type temporaryPostRow struct {
|
||||
PostID string
|
||||
Type string
|
||||
ExpireAt int64
|
||||
Message string
|
||||
FileIDs string
|
||||
}
|
||||
|
||||
var row temporaryPostRow
|
||||
err := s.DBXFromContext(rctx.Context()).GetBuilder(&row, query)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, store.NewErrNotFound("TemporaryPost", id)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to get TemporaryPost with id=%s: %w", id, err)
|
||||
}
|
||||
|
||||
// Parse FileIds from JSON string
|
||||
var fileIds model.StringArray
|
||||
if err := json.Unmarshal([]byte(row.FileIDs), &fileIds); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FileIds for TemporaryPost with id=%s: %w", id, err)
|
||||
}
|
||||
|
||||
post := &model.TemporaryPost{
|
||||
ID: row.PostID,
|
||||
Type: row.Type,
|
||||
ExpireAt: row.ExpireAt,
|
||||
Message: row.Message,
|
||||
FileIDs: fileIds,
|
||||
}
|
||||
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *SqlTemporaryPostStore) Delete(rctx request.CTX, id string) error {
|
||||
query := s.getQueryBuilder().
|
||||
Delete("TemporaryPosts").
|
||||
Where(sq.Eq{"PostId": id})
|
||||
|
||||
_, err := s.GetMaster().ExecBuilder(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete TemporaryPost with id=%s: %w", id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlTemporaryPostStore) GetExpiredPosts(rctx request.CTX) ([]string, error) {
|
||||
now := model.GetMillis()
|
||||
|
||||
query := s.getQueryBuilder().
|
||||
Select("PostId").
|
||||
From("TemporaryPosts").
|
||||
Where(sq.LtOrEq{"ExpireAt": now})
|
||||
|
||||
ids := []string{}
|
||||
err := s.GetMaster().SelectBuilder(&ids, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to select expired TemporaryPosts with expireAt<=%d: %w", now, err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
14
server/channels/store/sqlstore/temporary_post_store_test.go
Normal file
14
server/channels/store/sqlstore/temporary_post_store_test.go
Normal file
|
|
@ -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 TestTemporaryPostStore(t *testing.T) {
|
||||
StoreTestWithSqlStore(t, storetest.TestTemporaryPostStore)
|
||||
}
|
||||
|
|
@ -98,6 +98,8 @@ type Store interface {
|
|||
AutoTranslation() AutoTranslationStore
|
||||
GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, error)
|
||||
ContentFlagging() ContentFlaggingStore
|
||||
ReadReceipt() ReadReceiptStore
|
||||
TemporaryPost() TemporaryPostStore
|
||||
}
|
||||
|
||||
type RetentionPolicyStore interface {
|
||||
|
|
@ -617,7 +619,9 @@ type SystemStore interface {
|
|||
SaveOrUpdate(system *model.System) error
|
||||
Update(system *model.System) error
|
||||
Get() (model.StringMap, error)
|
||||
GetWithContext(rctx request.CTX) (model.StringMap, error)
|
||||
GetByName(name string) (*model.System, error)
|
||||
GetByNameWithContext(rctx request.CTX, name string) (*model.System, error)
|
||||
PermanentDeleteByName(name string) (*model.System, error)
|
||||
InsertIfExists(system *model.System) (*model.System, error)
|
||||
}
|
||||
|
|
@ -1180,6 +1184,26 @@ type ContentFlaggingStore interface {
|
|||
ClearCaches()
|
||||
}
|
||||
|
||||
type ReadReceiptStore interface {
|
||||
InvalidateReadReceiptForPostsCache(postID string)
|
||||
Save(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error)
|
||||
Update(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error)
|
||||
Delete(rctx request.CTX, postID, userID string) error
|
||||
DeleteByPost(rctx request.CTX, postID string) error
|
||||
Get(rctx request.CTX, postID, userID string) (*model.ReadReceipt, error)
|
||||
GetByPost(rctx request.CTX, postID string) ([]*model.ReadReceipt, error)
|
||||
GetReadCountForPost(rctx request.CTX, postID string) (int64, error)
|
||||
GetUnreadCountForPost(rctx request.CTX, post *model.Post) (int64, error)
|
||||
}
|
||||
|
||||
type TemporaryPostStore interface {
|
||||
InvalidateTemporaryPost(id string)
|
||||
Save(rctx request.CTX, post *model.TemporaryPost) (*model.TemporaryPost, error)
|
||||
Get(rctx request.CTX, id string) (*model.TemporaryPost, error)
|
||||
Delete(rctx request.CTX, id string) error
|
||||
GetExpiredPosts(rctx request.CTX) ([]string, error)
|
||||
}
|
||||
|
||||
// ChannelSearchOpts contains options for searching channels.
|
||||
//
|
||||
// NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records.
|
||||
|
|
|
|||
|
|
@ -6421,6 +6421,24 @@ func testAutocomplete(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore
|
|||
})
|
||||
}
|
||||
|
||||
// MM-67049: Verify that users removed from a team cannot see channels from that
|
||||
// team, regardless of includeDeleted. The includeDeleted parameter should only
|
||||
// affect channel deletion status, not team membership.
|
||||
t.Run("MM-67049: removed team member cannot see channels regardless of includeDeleted", func(t *testing.T) {
|
||||
// Sanity check: o5 is in leftTeamID and matches search term
|
||||
require.Equal(t, leftTeamID, o5.TeamId)
|
||||
require.Contains(t, o5.DisplayName, "ChannelA")
|
||||
|
||||
// m1.UserId was removed from leftTeamID (tm5.DeleteAt was set above in the test setup)
|
||||
for _, includeDeleted := range []bool{false, true} {
|
||||
channels, err2 := ss.Channel().Autocomplete(rctx, m1.UserId, "ChannelA", includeDeleted, false)
|
||||
require.NoError(t, err2)
|
||||
for _, ch := range channels {
|
||||
require.NotEqual(t, o5.Id, ch.Id, "includeDeleted=%v: channel from left team should not be returned", includeDeleted)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Limit", func(t *testing.T) {
|
||||
for i := range model.ChannelSearchDefaultLimit + 10 {
|
||||
_, err = ss.Channel().Save(rctx, &model.Channel{
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ func TestFileInfoStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStor
|
|||
t.Run("FileInfoGetByIds", func(t *testing.T) { testGetByIds(t, rctx, ss) })
|
||||
t.Run("FileInfoDeleteForPostByIds", func(t *testing.T) { testDeleteForPostByIds(t, rctx, ss) })
|
||||
t.Run("FileInfoRestoreForPostByIds", func(t *testing.T) { testRestoreUndeleteForPostByIds(t, rctx, ss) })
|
||||
t.Run("FileInfoSearch", func(t *testing.T) { testFileInfoSearch(t, rctx, ss) })
|
||||
}
|
||||
|
||||
func testFileInfoSaveGet(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
|
|
@ -1539,3 +1540,210 @@ func testRestoreUndeleteForPostByIds(t *testing.T, rctx request.CTX, ss store.St
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testFileInfoSearch(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
t.Run("should exclude FileInfo records with PostIds in TemporaryPosts", func(t *testing.T) {
|
||||
// Create team, channel, and user
|
||||
teamID := model.NewId()
|
||||
userID := model.NewId()
|
||||
|
||||
channel := &model.Channel{
|
||||
TeamId: teamID,
|
||||
DisplayName: "Test Channel",
|
||||
Name: "test-channel-" + model.NewId(),
|
||||
Type: model.ChannelTypeOpen,
|
||||
}
|
||||
channel, err := ss.Channel().Save(rctx, channel, -1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create channel member
|
||||
_, err = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: channel.Id,
|
||||
UserId: userID,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create posts
|
||||
post1 := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
UserId: userID,
|
||||
Message: "post 1",
|
||||
}
|
||||
post1, err = ss.Post().Save(rctx, post1)
|
||||
require.NoError(t, err)
|
||||
|
||||
post2 := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
UserId: userID,
|
||||
Message: "post 2",
|
||||
}
|
||||
post2, err = ss.Post().Save(rctx, post2)
|
||||
require.NoError(t, err)
|
||||
|
||||
post3 := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
UserId: userID,
|
||||
Message: "post 3",
|
||||
}
|
||||
post3, err = ss.Post().Save(rctx, post3)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create FileInfo records attached to posts
|
||||
fileInfo1, err := ss.FileInfo().Save(rctx, &model.FileInfo{
|
||||
PostId: post1.Id,
|
||||
ChannelId: channel.Id,
|
||||
CreatorId: userID,
|
||||
Path: "file1.txt",
|
||||
Name: "file1.txt",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ss.FileInfo().PermanentDelete(rctx, fileInfo1.Id)
|
||||
|
||||
fileInfo2, err := ss.FileInfo().Save(rctx, &model.FileInfo{
|
||||
PostId: post2.Id,
|
||||
ChannelId: channel.Id,
|
||||
CreatorId: userID,
|
||||
Path: "file2.txt",
|
||||
Name: "file2.txt",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ss.FileInfo().PermanentDelete(rctx, fileInfo2.Id)
|
||||
|
||||
fileInfo3, err := ss.FileInfo().Save(rctx, &model.FileInfo{
|
||||
PostId: post3.Id,
|
||||
ChannelId: channel.Id,
|
||||
CreatorId: userID,
|
||||
Path: "file3.txt",
|
||||
Name: "file3.txt",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ss.FileInfo().PermanentDelete(rctx, fileInfo3.Id)
|
||||
|
||||
// Create TemporaryPost for post2 (fileInfo2 should be excluded)
|
||||
tmpPost := &model.TemporaryPost{
|
||||
ID: post2.Id,
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
ExpireAt: model.GetMillis() + 3600000, // 1 hour from now
|
||||
Message: "temporary message",
|
||||
FileIDs: []string{fileInfo2.Id},
|
||||
}
|
||||
_, err = ss.TemporaryPost().Save(rctx, tmpPost)
|
||||
require.NoError(t, err)
|
||||
defer ss.TemporaryPost().Delete(rctx, tmpPost.ID)
|
||||
|
||||
// Search for files - should exclude fileInfo2 since its PostId is in TemporaryPosts
|
||||
// Use empty terms to search all files in the channel (works for both PostgreSQL and MySQL)
|
||||
paramsList := []*model.SearchParams{
|
||||
{
|
||||
Terms: "",
|
||||
InChannels: []string{channel.Id},
|
||||
SearchWithoutUserId: false,
|
||||
},
|
||||
}
|
||||
|
||||
results, err := ss.FileInfo().Search(rctx, paramsList, userID, teamID, 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, results)
|
||||
|
||||
// Verify fileInfo2 is excluded (PostId in TemporaryPosts)
|
||||
// fileInfo1 and fileInfo3 should be included
|
||||
foundFileIds := make(map[string]bool)
|
||||
for _, fileInfo := range results.FileInfos {
|
||||
foundFileIds[fileInfo.Id] = true
|
||||
}
|
||||
|
||||
assert.True(t, foundFileIds[fileInfo1.Id], "fileInfo1 should be included in search results")
|
||||
assert.False(t, foundFileIds[fileInfo2.Id], "fileInfo2 should be excluded from search results (PostId in TemporaryPosts)")
|
||||
assert.True(t, foundFileIds[fileInfo3.Id], "fileInfo3 should be included in search results")
|
||||
})
|
||||
|
||||
t.Run("should include FileInfo records when TemporaryPost is deleted", func(t *testing.T) {
|
||||
// Create team, channel, and user
|
||||
teamID := model.NewId()
|
||||
userID := model.NewId()
|
||||
|
||||
channel := &model.Channel{
|
||||
TeamId: teamID,
|
||||
DisplayName: "Test Channel 2",
|
||||
Name: "test-channel-" + model.NewId(),
|
||||
Type: model.ChannelTypeOpen,
|
||||
}
|
||||
channel, err := ss.Channel().Save(rctx, channel, -1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create channel member
|
||||
_, err = ss.Channel().SaveMember(rctx, &model.ChannelMember{
|
||||
ChannelId: channel.Id,
|
||||
UserId: userID,
|
||||
NotifyProps: model.GetDefaultChannelNotifyProps(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create post
|
||||
post := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
UserId: userID,
|
||||
Message: "post",
|
||||
}
|
||||
post, err = ss.Post().Save(rctx, post)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create FileInfo attached to post
|
||||
fileInfo, err := ss.FileInfo().Save(rctx, &model.FileInfo{
|
||||
PostId: post.Id,
|
||||
ChannelId: channel.Id,
|
||||
CreatorId: userID,
|
||||
Path: "file.txt",
|
||||
Name: "file.txt",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer ss.FileInfo().PermanentDelete(rctx, fileInfo.Id)
|
||||
|
||||
// Create TemporaryPost for the post
|
||||
tmpPost := &model.TemporaryPost{
|
||||
ID: post.Id,
|
||||
Type: model.PostTypeBurnOnRead,
|
||||
ExpireAt: model.GetMillis() + 3600000,
|
||||
Message: "temporary message",
|
||||
FileIDs: []string{fileInfo.Id},
|
||||
}
|
||||
_, err = ss.TemporaryPost().Save(rctx, tmpPost)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Search - fileInfo should be excluded
|
||||
// Use empty terms to search all files in the channel (works for both PostgreSQL and MySQL)
|
||||
paramsList := []*model.SearchParams{
|
||||
{
|
||||
Terms: "",
|
||||
InChannels: []string{channel.Id},
|
||||
SearchWithoutUserId: false,
|
||||
},
|
||||
}
|
||||
|
||||
results, err := ss.FileInfo().Search(rctx, paramsList, userID, teamID, 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, results)
|
||||
|
||||
foundFileIds := make(map[string]bool)
|
||||
for _, fi := range results.FileInfos {
|
||||
foundFileIds[fi.Id] = true
|
||||
}
|
||||
assert.False(t, foundFileIds[fileInfo.Id], "fileInfo should be excluded when TemporaryPost exists")
|
||||
|
||||
// Delete TemporaryPost
|
||||
err = ss.TemporaryPost().Delete(rctx, tmpPost.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Search again - fileInfo should now be included
|
||||
results, err = ss.FileInfo().Search(rctx, paramsList, userID, teamID, 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, results)
|
||||
|
||||
foundFileIds = make(map[string]bool)
|
||||
for _, fi := range results.FileInfos {
|
||||
foundFileIds[fi.Id] = true
|
||||
}
|
||||
assert.True(t, foundFileIds[fileInfo.Id], "fileInfo should be included after TemporaryPost is deleted")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
247
server/channels/store/storetest/mocks/ReadReceiptStore.go
Normal file
247
server/channels/store/storetest/mocks/ReadReceiptStore.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make store-mocks`.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
request "github.com/mattermost/mattermost/server/public/shared/request"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// ReadReceiptStore is an autogenerated mock type for the ReadReceiptStore type
|
||||
type ReadReceiptStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: rctx, postID, userID
|
||||
func (_m *ReadReceiptStore) Delete(rctx request.CTX, postID string, userID string) error {
|
||||
ret := _m.Called(rctx, postID, userID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Delete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) error); ok {
|
||||
r0 = rf(rctx, postID, userID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// DeleteByPost provides a mock function with given fields: rctx, postID
|
||||
func (_m *ReadReceiptStore) DeleteByPost(rctx request.CTX, postID string) error {
|
||||
ret := _m.Called(rctx, postID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeleteByPost")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) error); ok {
|
||||
r0 = rf(rctx, postID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Get provides a mock function with given fields: rctx, postID, userID
|
||||
func (_m *ReadReceiptStore) Get(rctx request.CTX, postID string, userID string) (*model.ReadReceipt, error) {
|
||||
ret := _m.Called(rctx, postID, userID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Get")
|
||||
}
|
||||
|
||||
var r0 *model.ReadReceipt
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (*model.ReadReceipt, error)); ok {
|
||||
return rf(rctx, postID, userID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.ReadReceipt); ok {
|
||||
r0 = rf(rctx, postID, userID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.ReadReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, string) error); ok {
|
||||
r1 = rf(rctx, postID, userID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetByPost provides a mock function with given fields: rctx, postID
|
||||
func (_m *ReadReceiptStore) GetByPost(rctx request.CTX, postID string) ([]*model.ReadReceipt, error) {
|
||||
ret := _m.Called(rctx, postID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetByPost")
|
||||
}
|
||||
|
||||
var r0 []*model.ReadReceipt
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) ([]*model.ReadReceipt, error)); ok {
|
||||
return rf(rctx, postID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) []*model.ReadReceipt); ok {
|
||||
r0 = rf(rctx, postID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.ReadReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok {
|
||||
r1 = rf(rctx, postID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetReadCountForPost provides a mock function with given fields: rctx, postID
|
||||
func (_m *ReadReceiptStore) GetReadCountForPost(rctx request.CTX, postID string) (int64, error) {
|
||||
ret := _m.Called(rctx, postID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetReadCountForPost")
|
||||
}
|
||||
|
||||
var r0 int64
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) (int64, error)); ok {
|
||||
return rf(rctx, postID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) int64); ok {
|
||||
r0 = rf(rctx, postID)
|
||||
} else {
|
||||
r0 = ret.Get(0).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok {
|
||||
r1 = rf(rctx, postID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetUnreadCountForPost provides a mock function with given fields: rctx, post
|
||||
func (_m *ReadReceiptStore) GetUnreadCountForPost(rctx request.CTX, post *model.Post) (int64, error) {
|
||||
ret := _m.Called(rctx, post)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetUnreadCountForPost")
|
||||
}
|
||||
|
||||
var r0 int64
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post) (int64, error)); ok {
|
||||
return rf(rctx, post)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post) int64); ok {
|
||||
r0 = rf(rctx, post)
|
||||
} else {
|
||||
r0 = ret.Get(0).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.Post) error); ok {
|
||||
r1 = rf(rctx, post)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// InvalidateReadReceiptForPostsCache provides a mock function with given fields: postID
|
||||
func (_m *ReadReceiptStore) InvalidateReadReceiptForPostsCache(postID string) {
|
||||
_m.Called(postID)
|
||||
}
|
||||
|
||||
// Save provides a mock function with given fields: rctx, receipt
|
||||
func (_m *ReadReceiptStore) Save(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
ret := _m.Called(rctx, receipt)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Save")
|
||||
}
|
||||
|
||||
var r0 *model.ReadReceipt
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.ReadReceipt) (*model.ReadReceipt, error)); ok {
|
||||
return rf(rctx, receipt)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.ReadReceipt) *model.ReadReceipt); ok {
|
||||
r0 = rf(rctx, receipt)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.ReadReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.ReadReceipt) error); ok {
|
||||
r1 = rf(rctx, receipt)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: rctx, receipt
|
||||
func (_m *ReadReceiptStore) Update(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
ret := _m.Called(rctx, receipt)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Update")
|
||||
}
|
||||
|
||||
var r0 *model.ReadReceipt
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.ReadReceipt) (*model.ReadReceipt, error)); ok {
|
||||
return rf(rctx, receipt)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.ReadReceipt) *model.ReadReceipt); ok {
|
||||
r0 = rf(rctx, receipt)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.ReadReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.ReadReceipt) error); ok {
|
||||
r1 = rf(rctx, receipt)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewReadReceiptStore creates a new instance of ReadReceiptStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewReadReceiptStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *ReadReceiptStore {
|
||||
mock := &ReadReceiptStore{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
108
server/channels/store/storetest/mocks/ReadReceiptsStore.go
Normal file
108
server/channels/store/storetest/mocks/ReadReceiptsStore.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make store-mocks`.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
request "github.com/mattermost/mattermost/server/public/shared/request"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// ReadReceiptsStore is an autogenerated mock type for the ReadReceiptsStore type
|
||||
type ReadReceiptsStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: rctx, postID, userID
|
||||
func (_m *ReadReceiptsStore) Delete(rctx request.CTX, postID string, userID string) error {
|
||||
ret := _m.Called(rctx, postID, userID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Delete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) error); ok {
|
||||
r0 = rf(rctx, postID, userID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Get provides a mock function with given fields: rctx, postID, userID
|
||||
func (_m *ReadReceiptsStore) Get(rctx request.CTX, postID string, userID string) (*model.ReadReceipt, error) {
|
||||
ret := _m.Called(rctx, postID, userID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Get")
|
||||
}
|
||||
|
||||
var r0 *model.ReadReceipt
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (*model.ReadReceipt, error)); ok {
|
||||
return rf(rctx, postID, userID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.ReadReceipt); ok {
|
||||
r0 = rf(rctx, postID, userID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.ReadReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string, string) error); ok {
|
||||
r1 = rf(rctx, postID, userID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Save provides a mock function with given fields: rctx, receipt
|
||||
func (_m *ReadReceiptsStore) Save(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
ret := _m.Called(rctx, receipt)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Save")
|
||||
}
|
||||
|
||||
var r0 *model.ReadReceipt
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.ReadReceipt) (*model.ReadReceipt, error)); ok {
|
||||
return rf(rctx, receipt)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.ReadReceipt) *model.ReadReceipt); ok {
|
||||
r0 = rf(rctx, receipt)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.ReadReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.ReadReceipt) error); ok {
|
||||
r1 = rf(rctx, receipt)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewReadReceiptsStore creates a new instance of ReadReceiptsStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewReadReceiptsStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *ReadReceiptsStore {
|
||||
mock := &ReadReceiptsStore{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
|
@ -966,6 +966,26 @@ func (_m *Store) Reaction() store.ReactionStore {
|
|||
return r0
|
||||
}
|
||||
|
||||
// ReadReceipt provides a mock function with no fields
|
||||
func (_m *Store) ReadReceipt() store.ReadReceiptStore {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ReadReceipt")
|
||||
}
|
||||
|
||||
var r0 store.ReadReceiptStore
|
||||
if rf, ok := ret.Get(0).(func() store.ReadReceiptStore); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(store.ReadReceiptStore)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// RecycleDBConnections provides a mock function with given fields: d
|
||||
func (_m *Store) RecycleDBConnections(d time.Duration) {
|
||||
_m.Called(d)
|
||||
|
|
@ -1207,6 +1227,26 @@ func (_m *Store) Team() store.TeamStore {
|
|||
return r0
|
||||
}
|
||||
|
||||
// TemporaryPost provides a mock function with no fields
|
||||
func (_m *Store) TemporaryPost() store.TemporaryPostStore {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for TemporaryPost")
|
||||
}
|
||||
|
||||
var r0 store.TemporaryPostStore
|
||||
if rf, ok := ret.Get(0).(func() store.TemporaryPostStore); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(store.TemporaryPostStore)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// TermsOfService provides a mock function with no fields
|
||||
func (_m *Store) TermsOfService() store.TermsOfServiceStore {
|
||||
ret := _m.Called()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package mocks
|
|||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
request "github.com/mattermost/mattermost/server/public/shared/request"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
|
|
@ -74,6 +75,66 @@ func (_m *SystemStore) GetByName(name string) (*model.System, error) {
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// GetByNameWithContext provides a mock function with given fields: rctx, name
|
||||
func (_m *SystemStore) GetByNameWithContext(rctx request.CTX, name string) (*model.System, error) {
|
||||
ret := _m.Called(rctx, name)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetByNameWithContext")
|
||||
}
|
||||
|
||||
var r0 *model.System
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.System, error)); ok {
|
||||
return rf(rctx, name)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.System); ok {
|
||||
r0 = rf(rctx, name)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.System)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok {
|
||||
r1 = rf(rctx, name)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetWithContext provides a mock function with given fields: rctx
|
||||
func (_m *SystemStore) GetWithContext(rctx request.CTX) (model.StringMap, error) {
|
||||
ret := _m.Called(rctx)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetWithContext")
|
||||
}
|
||||
|
||||
var r0 model.StringMap
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX) (model.StringMap, error)); ok {
|
||||
return rf(rctx)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX) model.StringMap); ok {
|
||||
r0 = rf(rctx)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(model.StringMap)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX) error); ok {
|
||||
r1 = rf(rctx)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// InsertIfExists provides a mock function with given fields: system
|
||||
func (_m *SystemStore) InsertIfExists(system *model.System) (*model.System, error) {
|
||||
ret := _m.Called(system)
|
||||
|
|
|
|||
143
server/channels/store/storetest/mocks/TemporaryPostStore.go
Normal file
143
server/channels/store/storetest/mocks/TemporaryPostStore.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make store-mocks`.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
request "github.com/mattermost/mattermost/server/public/shared/request"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// TemporaryPostStore is an autogenerated mock type for the TemporaryPostStore type
|
||||
type TemporaryPostStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: rctx, id
|
||||
func (_m *TemporaryPostStore) Delete(rctx request.CTX, id string) error {
|
||||
ret := _m.Called(rctx, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Delete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) error); ok {
|
||||
r0 = rf(rctx, id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Get provides a mock function with given fields: rctx, id
|
||||
func (_m *TemporaryPostStore) Get(rctx request.CTX, id string) (*model.TemporaryPost, error) {
|
||||
ret := _m.Called(rctx, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Get")
|
||||
}
|
||||
|
||||
var r0 *model.TemporaryPost
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.TemporaryPost, error)); ok {
|
||||
return rf(rctx, id)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.TemporaryPost); ok {
|
||||
r0 = rf(rctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.TemporaryPost)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok {
|
||||
r1 = rf(rctx, id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetExpiredPosts provides a mock function with given fields: rctx
|
||||
func (_m *TemporaryPostStore) GetExpiredPosts(rctx request.CTX) ([]string, error) {
|
||||
ret := _m.Called(rctx)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetExpiredPosts")
|
||||
}
|
||||
|
||||
var r0 []string
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX) ([]string, error)); ok {
|
||||
return rf(rctx)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX) []string); ok {
|
||||
r0 = rf(rctx)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]string)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX) error); ok {
|
||||
r1 = rf(rctx)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// InvalidateTemporaryPost provides a mock function with given fields: id
|
||||
func (_m *TemporaryPostStore) InvalidateTemporaryPost(id string) {
|
||||
_m.Called(id)
|
||||
}
|
||||
|
||||
// Save provides a mock function with given fields: rctx, post
|
||||
func (_m *TemporaryPostStore) Save(rctx request.CTX, post *model.TemporaryPost) (*model.TemporaryPost, error) {
|
||||
ret := _m.Called(rctx, post)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Save")
|
||||
}
|
||||
|
||||
var r0 *model.TemporaryPost
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.TemporaryPost) (*model.TemporaryPost, error)); ok {
|
||||
return rf(rctx, post)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX, *model.TemporaryPost) *model.TemporaryPost); ok {
|
||||
r0 = rf(rctx, post)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.TemporaryPost)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX, *model.TemporaryPost) error); ok {
|
||||
r1 = rf(rctx, post)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewTemporaryPostStore creates a new instance of TemporaryPostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewTemporaryPostStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *TemporaryPostStore {
|
||||
mock := &TemporaryPostStore{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
73
server/channels/store/storetest/read_receipt_store.go
Normal file
73
server/channels/store/storetest/read_receipt_store.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package storetest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
func TestReadReceiptStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
|
||||
t.Run("GetReadCountForPost", func(t *testing.T) { testGetReadCountForPost(t, rctx, ss) })
|
||||
}
|
||||
|
||||
func testGetReadCountForPost(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
rrStore := ss.ReadReceipt()
|
||||
|
||||
receipt1 := &model.ReadReceipt{
|
||||
PostID: "post1",
|
||||
UserID: "user1",
|
||||
ExpireAt: 0,
|
||||
}
|
||||
receipt2 := &model.ReadReceipt{
|
||||
PostID: "post1",
|
||||
UserID: "user2",
|
||||
ExpireAt: 0,
|
||||
}
|
||||
receipt3 := &model.ReadReceipt{
|
||||
PostID: "post2",
|
||||
UserID: "user3",
|
||||
ExpireAt: 0,
|
||||
}
|
||||
|
||||
_, err := rrStore.Save(rctx, receipt1)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save read receipt 1: %v", err)
|
||||
}
|
||||
_, err = rrStore.Save(rctx, receipt2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save read receipt 2: %v", err)
|
||||
}
|
||||
_, err = rrStore.Save(rctx, receipt3)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save read receipt 3: %v", err)
|
||||
}
|
||||
|
||||
count, err := rrStore.GetReadCountForPost(rctx, "post1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get read count for post1: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected read count for post1 to be 2, got %d", count)
|
||||
}
|
||||
|
||||
count, err = rrStore.GetReadCountForPost(rctx, "post2")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get read count for post2: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected read count for post2 to be 1, got %d", count)
|
||||
}
|
||||
|
||||
count, err = rrStore.GetReadCountForPost(rctx, "post3")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get read count for post3: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected read count for post3 to be 0, got %d", count)
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +71,8 @@ type Store struct {
|
|||
AttributesStore mocks.AttributesStore
|
||||
AutoTranslationStore mocks.AutoTranslationStore
|
||||
ContentFlaggingStore mocks.ContentFlaggingStore
|
||||
ReadReceiptStore mocks.ReadReceiptStore
|
||||
TemporaryPostStore mocks.TemporaryPostStore
|
||||
}
|
||||
|
||||
func (s *Store) Logger() mlog.LoggerIFace { return s.logger }
|
||||
|
|
@ -167,7 +169,12 @@ func (s *Store) AutoTranslation() store.AutoTranslationStore {
|
|||
func (s *Store) ContentFlagging() store.ContentFlaggingStore {
|
||||
return &s.ContentFlaggingStore
|
||||
}
|
||||
|
||||
func (s *Store) ReadReceipt() store.ReadReceiptStore {
|
||||
return &s.ReadReceiptStore
|
||||
}
|
||||
func (s *Store) TemporaryPost() store.TemporaryPostStore {
|
||||
return &s.TemporaryPostStore
|
||||
}
|
||||
func (s *Store) GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, error) {
|
||||
return &model.SupportPacketDatabaseSchema{
|
||||
Tables: []model.DatabaseTable{},
|
||||
|
|
@ -220,5 +227,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool {
|
|||
&s.AttributesStore,
|
||||
&s.AutoTranslationStore,
|
||||
&s.ContentFlaggingStore,
|
||||
&s.ReadReceiptStore,
|
||||
&s.TemporaryPostStore,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
141
server/channels/store/storetest/temporary_post_store.go
Normal file
141
server/channels/store/storetest/temporary_post_store.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package storetest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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 TestTemporaryPostStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
|
||||
t.Run("Save", func(t *testing.T) { testTemporaryPostSave(t, rctx, ss) })
|
||||
t.Run("Get", func(t *testing.T) { testTemporaryPostGet(t, rctx, ss) })
|
||||
t.Run("Delete", func(t *testing.T) { testTemporaryPostDelete(t, rctx, ss) })
|
||||
t.Run("GetExpiredPosts", func(t *testing.T) { testTemporaryPostGetExpiredPosts(t, rctx, ss) })
|
||||
}
|
||||
|
||||
func testTemporaryPostSave(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
t.Run("should be able to save a temporary post", func(t *testing.T) {
|
||||
post := &model.TemporaryPost{
|
||||
ID: model.NewId(),
|
||||
Type: model.PostTypeDefault,
|
||||
ExpireAt: model.GetMillis() + 3600000, // 1 hour from now
|
||||
Message: "Test message",
|
||||
FileIDs: []string{"file1", "file2"},
|
||||
}
|
||||
|
||||
saved, err := ss.TemporaryPost().Save(rctx, post)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, post.ID, saved.ID)
|
||||
require.Equal(t, post.Message, saved.Message)
|
||||
require.Equal(t, post.FileIDs, saved.FileIDs)
|
||||
})
|
||||
|
||||
t.Run("should fail if id is empty", func(t *testing.T) {
|
||||
post := &model.TemporaryPost{
|
||||
ID: "",
|
||||
Type: model.PostTypeDefault,
|
||||
ExpireAt: model.GetMillis() + 3600000,
|
||||
Message: "Test message",
|
||||
}
|
||||
|
||||
_, err := ss.TemporaryPost().Save(rctx, post)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "id is required")
|
||||
})
|
||||
}
|
||||
|
||||
func testTemporaryPostGet(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
t.Run("should fail on nonexisting post", func(t *testing.T) {
|
||||
post, err := ss.TemporaryPost().Get(rctx, model.NewId())
|
||||
require.Nil(t, post)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("should be able to retrieve an existing temporary post", func(t *testing.T) {
|
||||
post := &model.TemporaryPost{
|
||||
ID: model.NewId(),
|
||||
Type: model.PostTypeDefault,
|
||||
ExpireAt: model.GetMillis() + 3600000,
|
||||
Message: "Test message for get",
|
||||
FileIDs: []string{"file1"},
|
||||
}
|
||||
|
||||
saved, err := ss.TemporaryPost().Save(rctx, post)
|
||||
require.NoError(t, err)
|
||||
|
||||
retrieved, err := ss.TemporaryPost().Get(rctx, saved.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, saved.ID, retrieved.ID)
|
||||
require.Equal(t, saved.Message, retrieved.Message)
|
||||
require.Equal(t, saved.FileIDs, retrieved.FileIDs)
|
||||
require.Equal(t, saved.Type, retrieved.Type)
|
||||
require.Equal(t, saved.ExpireAt, retrieved.ExpireAt)
|
||||
})
|
||||
}
|
||||
|
||||
func testTemporaryPostDelete(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
t.Run("should not fail on nonexistent post", func(t *testing.T) {
|
||||
err := ss.TemporaryPost().Delete(rctx, model.NewId())
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("should be able to delete an existing temporary post", func(t *testing.T) {
|
||||
post := &model.TemporaryPost{
|
||||
ID: model.NewId(),
|
||||
Type: model.PostTypeDefault,
|
||||
ExpireAt: model.GetMillis() + 3600000,
|
||||
Message: "Test message for delete",
|
||||
}
|
||||
|
||||
saved, err := ss.TemporaryPost().Save(rctx, post)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ss.TemporaryPost().Delete(rctx, saved.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's deleted
|
||||
retrieved, err := ss.TemporaryPost().Get(rctx, saved.ID)
|
||||
require.Nil(t, retrieved)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func testTemporaryPostGetExpiredPosts(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
t.Run("should get expired posts", func(t *testing.T) {
|
||||
now := model.GetMillis()
|
||||
pastTime := now - 3600000 // 1 hour ago
|
||||
|
||||
// Create expired post
|
||||
expiredPost := &model.TemporaryPost{
|
||||
ID: model.NewId(),
|
||||
Type: model.PostTypeDefault,
|
||||
ExpireAt: pastTime,
|
||||
Message: "Expired message",
|
||||
}
|
||||
_, err := ss.TemporaryPost().Save(rctx, expiredPost)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create non-expired post
|
||||
validPost := &model.TemporaryPost{
|
||||
ID: model.NewId(),
|
||||
Type: model.PostTypeDefault,
|
||||
ExpireAt: now + 3600000, // 1 hour from now
|
||||
Message: "Valid message",
|
||||
}
|
||||
_, err = ss.TemporaryPost().Save(rctx, validPost)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get expired posts
|
||||
expiredPosts, err := ss.TemporaryPost().GetExpiredPosts(rctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(expiredPosts))
|
||||
require.Equal(t, expiredPost.ID, expiredPosts[0])
|
||||
})
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ type TimerLayer struct {
|
|||
PropertyGroupStore store.PropertyGroupStore
|
||||
PropertyValueStore store.PropertyValueStore
|
||||
ReactionStore store.ReactionStore
|
||||
ReadReceiptStore store.ReadReceiptStore
|
||||
RemoteClusterStore store.RemoteClusterStore
|
||||
RetentionPolicyStore store.RetentionPolicyStore
|
||||
RoleStore store.RoleStore
|
||||
|
|
@ -64,6 +65,7 @@ type TimerLayer struct {
|
|||
StatusStore store.StatusStore
|
||||
SystemStore store.SystemStore
|
||||
TeamStore store.TeamStore
|
||||
TemporaryPostStore store.TemporaryPostStore
|
||||
TermsOfServiceStore store.TermsOfServiceStore
|
||||
ThreadStore store.ThreadStore
|
||||
TokenStore store.TokenStore
|
||||
|
|
@ -214,6 +216,10 @@ func (s *TimerLayer) Reaction() store.ReactionStore {
|
|||
return s.ReactionStore
|
||||
}
|
||||
|
||||
func (s *TimerLayer) ReadReceipt() store.ReadReceiptStore {
|
||||
return s.ReadReceiptStore
|
||||
}
|
||||
|
||||
func (s *TimerLayer) RemoteCluster() store.RemoteClusterStore {
|
||||
return s.RemoteClusterStore
|
||||
}
|
||||
|
|
@ -254,6 +260,10 @@ func (s *TimerLayer) Team() store.TeamStore {
|
|||
return s.TeamStore
|
||||
}
|
||||
|
||||
func (s *TimerLayer) TemporaryPost() store.TemporaryPostStore {
|
||||
return s.TemporaryPostStore
|
||||
}
|
||||
|
||||
func (s *TimerLayer) TermsOfService() store.TermsOfServiceStore {
|
||||
return s.TermsOfServiceStore
|
||||
}
|
||||
|
|
@ -461,6 +471,11 @@ type TimerLayerReactionStore struct {
|
|||
Root *TimerLayer
|
||||
}
|
||||
|
||||
type TimerLayerReadReceiptStore struct {
|
||||
store.ReadReceiptStore
|
||||
Root *TimerLayer
|
||||
}
|
||||
|
||||
type TimerLayerRemoteClusterStore struct {
|
||||
store.RemoteClusterStore
|
||||
Root *TimerLayer
|
||||
|
|
@ -511,6 +526,11 @@ type TimerLayerTeamStore struct {
|
|||
Root *TimerLayer
|
||||
}
|
||||
|
||||
type TimerLayerTemporaryPostStore struct {
|
||||
store.TemporaryPostStore
|
||||
Root *TimerLayer
|
||||
}
|
||||
|
||||
type TimerLayerTermsOfServiceStore struct {
|
||||
store.TermsOfServiceStore
|
||||
Root *TimerLayer
|
||||
|
|
@ -6790,6 +6810,22 @@ func (s *TimerLayerPostStore) GetPostsCreatedAt(channelID string, timestamp int6
|
|||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostStore) GetPostsForReporting(rctx request.CTX, queryParams model.ReportPostQueryParams) (*model.ReportPostListResponse, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.PostStore.GetPostsForReporting(rctx, queryParams)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("PostStore.GetPostsForReporting", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerPostStore) GetPostsSince(rctx request.CTX, options model.GetPostsSinceOptions, allowFromCache bool, sanitizeOptions map[string]bool) (*model.PostList, error) {
|
||||
start := time.Now()
|
||||
|
||||
|
|
@ -8245,6 +8281,149 @@ func (s *TimerLayerReactionStore) Save(reaction *model.Reaction) (*model.Reactio
|
|||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerReadReceiptStore) Delete(rctx request.CTX, postID string, userID string) error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.ReadReceiptStore.Delete(rctx, postID, userID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ReadReceiptStore.Delete", success, elapsed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerReadReceiptStore) DeleteByPost(rctx request.CTX, postID string) error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.ReadReceiptStore.DeleteByPost(rctx, postID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ReadReceiptStore.DeleteByPost", success, elapsed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerReadReceiptStore) Get(rctx request.CTX, postID string, userID string) (*model.ReadReceipt, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.ReadReceiptStore.Get(rctx, postID, userID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ReadReceiptStore.Get", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerReadReceiptStore) GetByPost(rctx request.CTX, postID string) ([]*model.ReadReceipt, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.ReadReceiptStore.GetByPost(rctx, postID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ReadReceiptStore.GetByPost", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerReadReceiptStore) GetReadCountForPost(rctx request.CTX, postID string) (int64, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.ReadReceiptStore.GetReadCountForPost(rctx, postID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ReadReceiptStore.GetReadCountForPost", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerReadReceiptStore) GetUnreadCountForPost(rctx request.CTX, post *model.Post) (int64, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.ReadReceiptStore.GetUnreadCountForPost(rctx, post)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ReadReceiptStore.GetUnreadCountForPost", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerReadReceiptStore) InvalidateReadReceiptForPostsCache(postID string) {
|
||||
start := time.Now()
|
||||
|
||||
s.ReadReceiptStore.InvalidateReadReceiptForPostsCache(postID)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if true {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ReadReceiptStore.InvalidateReadReceiptForPostsCache", success, elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TimerLayerReadReceiptStore) Save(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.ReadReceiptStore.Save(rctx, receipt)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ReadReceiptStore.Save", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerReadReceiptStore) Update(rctx request.CTX, receipt *model.ReadReceipt) (*model.ReadReceipt, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.ReadReceiptStore.Update(rctx, receipt)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("ReadReceiptStore.Update", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerRemoteClusterStore) Delete(remoteClusterID string) (bool, error) {
|
||||
start := time.Now()
|
||||
|
||||
|
|
@ -10053,6 +10232,38 @@ func (s *TimerLayerSystemStore) GetByName(name string) (*model.System, error) {
|
|||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerSystemStore) GetByNameWithContext(rctx request.CTX, name string) (*model.System, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.SystemStore.GetByNameWithContext(rctx, name)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("SystemStore.GetByNameWithContext", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerSystemStore) GetWithContext(rctx request.CTX) (model.StringMap, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.SystemStore.GetWithContext(rctx)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("SystemStore.GetWithContext", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerSystemStore) InsertIfExists(system *model.System) (*model.System, error) {
|
||||
start := time.Now()
|
||||
|
||||
|
|
@ -10963,6 +11174,85 @@ func (s *TimerLayerTeamStore) UserBelongsToTeams(userID string, teamIds []string
|
|||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerTemporaryPostStore) Delete(rctx request.CTX, id string) error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.TemporaryPostStore.Delete(rctx, id)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("TemporaryPostStore.Delete", success, elapsed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerTemporaryPostStore) Get(rctx request.CTX, id string) (*model.TemporaryPost, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.TemporaryPostStore.Get(rctx, id)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("TemporaryPostStore.Get", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerTemporaryPostStore) GetExpiredPosts(rctx request.CTX) ([]string, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.TemporaryPostStore.GetExpiredPosts(rctx)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("TemporaryPostStore.GetExpiredPosts", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerTemporaryPostStore) InvalidateTemporaryPost(id string) {
|
||||
start := time.Now()
|
||||
|
||||
s.TemporaryPostStore.InvalidateTemporaryPost(id)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if true {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("TemporaryPostStore.InvalidateTemporaryPost", success, elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TimerLayerTemporaryPostStore) Save(rctx request.CTX, post *model.TemporaryPost) (*model.TemporaryPost, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.TemporaryPostStore.Save(rctx, post)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("TemporaryPostStore.Save", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerTermsOfServiceStore) Get(id string, allowFromCache bool) (*model.TermsOfService, error) {
|
||||
start := time.Now()
|
||||
|
||||
|
|
@ -13733,6 +14023,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay
|
|||
newStore.PropertyGroupStore = &TimerLayerPropertyGroupStore{PropertyGroupStore: childStore.PropertyGroup(), Root: &newStore}
|
||||
newStore.PropertyValueStore = &TimerLayerPropertyValueStore{PropertyValueStore: childStore.PropertyValue(), Root: &newStore}
|
||||
newStore.ReactionStore = &TimerLayerReactionStore{ReactionStore: childStore.Reaction(), Root: &newStore}
|
||||
newStore.ReadReceiptStore = &TimerLayerReadReceiptStore{ReadReceiptStore: childStore.ReadReceipt(), Root: &newStore}
|
||||
newStore.RemoteClusterStore = &TimerLayerRemoteClusterStore{RemoteClusterStore: childStore.RemoteCluster(), Root: &newStore}
|
||||
newStore.RetentionPolicyStore = &TimerLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore}
|
||||
newStore.RoleStore = &TimerLayerRoleStore{RoleStore: childStore.Role(), Root: &newStore}
|
||||
|
|
@ -13743,6 +14034,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay
|
|||
newStore.StatusStore = &TimerLayerStatusStore{StatusStore: childStore.Status(), Root: &newStore}
|
||||
newStore.SystemStore = &TimerLayerSystemStore{SystemStore: childStore.System(), Root: &newStore}
|
||||
newStore.TeamStore = &TimerLayerTeamStore{TeamStore: childStore.Team(), Root: &newStore}
|
||||
newStore.TemporaryPostStore = &TimerLayerTemporaryPostStore{TemporaryPostStore: childStore.TemporaryPost(), Root: &newStore}
|
||||
newStore.TermsOfServiceStore = &TimerLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore}
|
||||
newStore.ThreadStore = &TimerLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore}
|
||||
newStore.TokenStore = &TimerLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,10 @@ func GetMockStoreForSetupFunctions() *mocks.Store {
|
|||
systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(&model.System{}, nil).Once()
|
||||
systemStore.On("Save", mock.AnythingOfType("*model.System")).Return(nil)
|
||||
systemStore.On("SaveOrUpdate", mock.AnythingOfType("*model.System")).Return(nil)
|
||||
systemStore.On("Get").Return(model.StringMap{model.SystemServerId: model.NewId()}, nil)
|
||||
|
||||
diagnosticID := model.NewId()
|
||||
systemStore.On("Get").Return(model.StringMap{model.SystemServerId: diagnosticID}, nil)
|
||||
systemStore.On("GetByNameWithContext", mock.Anything, model.SystemServerId).Return(&model.System{Name: model.SystemServerId, Value: diagnosticID}, nil)
|
||||
|
||||
userStore := mocks.UserStore{}
|
||||
userStore.On("Count", mock.AnythingOfType("model.UserCountOptions")).Return(int64(1), nil)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ func loginWithMagicLinkToken(c *Context, w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
if c.AppContext.Session() != nil && c.AppContext.Session().UserId != "" {
|
||||
http.Redirect(w, r, c.GetSiteURLHeader()+"/error?type=magic_link_already_logged_in", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := r.URL.Query().Get("t")
|
||||
if tokenString == "" {
|
||||
c.Err = model.NewAppError("loginWithMagicLinkToken", "api.user.guest_magic_link.missing_token.app_error", nil, "", http.StatusBadRequest)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue