Compare commits

...

45 commits

Author SHA1 Message Date
Mattermost Build
7d4b795e32
update mscfb and msoleps indirect dependencies to fix oom vuln. (#34910) (#35050)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Automatic Merge
2026-01-26 13:23:31 +02:00
Mattermost Build
e8c9f34e69
MM-67055: Fix permalink embeds in WebSocket messages (#34893) (#35048)
Automatic Merge
2026-01-26 12:53:30 +02:00
Mattermost Build
666644d003
Add missing check (#35034) (#35046)
Automatic Merge
2026-01-26 12:23:32 +02:00
Mattermost Build
59272d115b
Returning pending post ID for post creator for BoR post (#34758) (#34998)
Some checks failed
Server CI / Compute Go Version (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Server CI / Check mocks (push) Has been cancelled
Server CI / Check go mod tidy (push) Has been cancelled
Server CI / check-style (push) Has been cancelled
Server CI / Check serialization methods for hot structs (push) Has been cancelled
Server CI / Vet API (push) Has been cancelled
Server CI / Check migration files (push) Has been cancelled
Server CI / Generate email templates (push) Has been cancelled
Server CI / Check store layers (push) Has been cancelled
Server CI / Check mmctl docs (push) Has been cancelled
Server CI / Postgres with binary parameters (push) Has been cancelled
Server CI / Postgres (push) Has been cancelled
Server CI / Postgres (FIPS) (push) Has been cancelled
Server CI / Generate Test Coverage (push) Has been cancelled
Server CI / Run mmctl tests (push) Has been cancelled
Server CI / Run mmctl tests (FIPS) (push) Has been cancelled
Server CI / Build mattermost server app (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
Automatic Merge
2026-01-20 15:54:28 +02:00
Mattermost Build
1fe1049198
MM-67077: Remove PSD file previews (#34898) (#34995)
Automatic Merge
2026-01-20 15:24:28 +02:00
Mattermost Build
a296621e3b
MM-67049: Fix unauthorized access to public channels in private teams (#34886) (#34992)
Automatic Merge
2026-01-20 14:54:27 +02:00
Mattermost Build
6edc7d4bc8
Guest cannot add file to post without upload_file permission (#34538) (#34989)
Automatic Merge
2026-01-20 14:24:26 +02:00
Mattermost Build
71f013648a
Fliter post in search api with no read content channel permission (#34620) (#34988)
Automatic Merge
2026-01-20 13:54:27 +02:00
Mattermost Build
21220ccfec
[MM-66789] Restrict ImportSettings.Directory changes via API and add validation (#34653) (#34985)
Automatic Merge
2026-01-20 13:24:26 +02:00
Mattermost Build
44bb4c2db6
[MM-66827] Omit invite_id from team creation response based on permissions (#34693) (#34970)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Automatic Merge
2026-01-19 13:54:25 +02:00
Rajat Dabade
2588c47b20
Board version upgrade to v9.2.2 for 11.3 (#34897)
Some checks failed
Server CI / Compute Go Version (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Server CI / Check mocks (push) Has been cancelled
Server CI / Check go mod tidy (push) Has been cancelled
Server CI / check-style (push) Has been cancelled
Server CI / Check serialization methods for hot structs (push) Has been cancelled
Server CI / Vet API (push) Has been cancelled
Server CI / Check migration files (push) Has been cancelled
Server CI / Generate email templates (push) Has been cancelled
Server CI / Check store layers (push) Has been cancelled
Server CI / Check mmctl docs (push) Has been cancelled
Server CI / Postgres with binary parameters (push) Has been cancelled
Server CI / Postgres (push) Has been cancelled
Server CI / Postgres (FIPS) (push) Has been cancelled
Server CI / Generate Test Coverage (push) Has been cancelled
Server CI / Run mmctl tests (push) Has been cancelled
Server CI / Run mmctl tests (FIPS) (push) Has been cancelled
Server CI / Build mattermost server app (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
2026-01-10 03:53:06 +05:30
unified-ci-app[bot]
523c566727
Update latest patch version to 11.3.1 (#34875)
Some checks failed
Server CI / Compute Go Version (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Server CI / Check mocks (push) Has been cancelled
Server CI / Check go mod tidy (push) Has been cancelled
Server CI / check-style (push) Has been cancelled
Server CI / Check serialization methods for hot structs (push) Has been cancelled
Server CI / Vet API (push) Has been cancelled
Server CI / Check migration files (push) Has been cancelled
Server CI / Generate email templates (push) Has been cancelled
Server CI / Check store layers (push) Has been cancelled
Server CI / Check mmctl docs (push) Has been cancelled
Server CI / Postgres with binary parameters (push) Has been cancelled
Server CI / Postgres (push) Has been cancelled
Server CI / Postgres (FIPS) (push) Has been cancelled
Server CI / Generate Test Coverage (push) Has been cancelled
Server CI / Run mmctl tests (push) Has been cancelled
Server CI / Run mmctl tests (FIPS) (push) Has been cancelled
Server CI / Build mattermost server app (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
Automatic Merge
2026-01-08 10:17:31 +02:00
Mattermost Build
d27a219506
Removing Alpha Label (#34806) (#34842)
Some checks failed
Server CI / Compute Go Version (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Server CI / Check mocks (push) Has been cancelled
Server CI / Check go mod tidy (push) Has been cancelled
Server CI / check-style (push) Has been cancelled
Server CI / Check serialization methods for hot structs (push) Has been cancelled
Server CI / Vet API (push) Has been cancelled
Server CI / Check migration files (push) Has been cancelled
Server CI / Generate email templates (push) Has been cancelled
Server CI / Check store layers (push) Has been cancelled
Server CI / Check mmctl docs (push) Has been cancelled
Server CI / Postgres with binary parameters (push) Has been cancelled
Server CI / Postgres (push) Has been cancelled
Server CI / Postgres (FIPS) (push) Has been cancelled
Server CI / Generate Test Coverage (push) Has been cancelled
Server CI / Run mmctl tests (push) Has been cancelled
Server CI / Run mmctl tests (FIPS) (push) Has been cancelled
Server CI / Build mattermost server app (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
Automatic Merge
2026-01-05 10:02:00 +02:00
Mattermost Build
3051ead599
MM-66910 - Fix isPostInteractable to exclude burn-on-read posts (#34764) (#34831)
Automatic Merge
2026-01-05 09:02:00 +02:00
Mattermost Build
c70e184b65
Bor post disable flagging (#34759) (#34824)
Automatic Merge
2026-01-05 08:32:00 +02:00
Mattermost Build
4356b092dd
MM-66924 - MarkAllThreadsAsReadModal to use ConfirmModal (#34732) (#34828)
Some checks failed
Server CI / Compute Go Version (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Server CI / Check mocks (push) Has been cancelled
Server CI / Check go mod tidy (push) Has been cancelled
Server CI / check-style (push) Has been cancelled
Server CI / Check serialization methods for hot structs (push) Has been cancelled
Server CI / Vet API (push) Has been cancelled
Server CI / Check migration files (push) Has been cancelled
Server CI / Generate email templates (push) Has been cancelled
Server CI / Check store layers (push) Has been cancelled
Server CI / Check mmctl docs (push) Has been cancelled
Server CI / Postgres with binary parameters (push) Has been cancelled
Server CI / Postgres (push) Has been cancelled
Server CI / Postgres (FIPS) (push) Has been cancelled
Server CI / Generate Test Coverage (push) Has been cancelled
Server CI / Run mmctl tests (push) Has been cancelled
Server CI / Run mmctl tests (FIPS) (push) Has been cancelled
Server CI / Build mattermost server app (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
* MM-66924 - MarkAllThreadsAsReadModal to use ConfirmModal and remove unused styles

* adjust translations

* adjust texts as requested and do not use confirm modal

* update mark all threads as read modal description for clarity

(cherry picked from commit 732ddaeae4)

Co-authored-by: Pablo Vélez <pablovv2012@gmail.com>
2025-12-23 22:04:26 +01:00
Mattermost Build
2d6b2ae112
MM-66891 - show only timer for sender when all recipients reveal message (#34745) (#34804)
Some checks failed
Server CI / Compute Go Version (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Server CI / Check mocks (push) Has been cancelled
Server CI / Check go mod tidy (push) Has been cancelled
Server CI / check-style (push) Has been cancelled
Server CI / Check serialization methods for hot structs (push) Has been cancelled
Server CI / Vet API (push) Has been cancelled
Server CI / Check migration files (push) Has been cancelled
Server CI / Generate email templates (push) Has been cancelled
Server CI / Check store layers (push) Has been cancelled
Server CI / Check mmctl docs (push) Has been cancelled
Server CI / Postgres with binary parameters (push) Has been cancelled
Server CI / Postgres (push) Has been cancelled
Server CI / Postgres (FIPS) (push) Has been cancelled
Server CI / Generate Test Coverage (push) Has been cancelled
Server CI / Run mmctl tests (push) Has been cancelled
Server CI / Run mmctl tests (FIPS) (push) Has been cancelled
Server CI / Build mattermost server app (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
(cherry picked from commit a7e5b3b0a0)

Co-authored-by: Pablo Vélez <pablovv2012@gmail.com>
2025-12-19 22:52:44 +01:00
Mattermost Build
6145da452c
MM-66925 - improve user email and password modals (#34739) (#34810)
* MM-66925 - improve user email and password  modals

* adjust error modal styling

* adjust e2e tests

(cherry picked from commit 1a21d34aab)

Co-authored-by: Pablo Vélez <pablovv2012@gmail.com>
2025-12-19 22:46:58 +01:00
Mattermost Build
69b019d53e
Bumping prepackaged Jira Plugin version to v4.5.0 (#34803) (#34808)
(cherry picked from commit a17bb19844)

Co-authored-by: Andre Vasconcelos <a-andre.vasconcelos@mattermost.com>
2025-12-19 22:11:38 +02:00
Mattermost Build
cd33bc9067
Automated cherry pick of #34763 (#34795)
* MM-66890 - Set default for BurnOnRead feature flag to true (#34763)

* MM-66890 - Set default for BurnOnRead feature flag to true

* enable the feature by default

* fix unit tests

* fix e2e tests

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
(cherry picked from commit e0052be9c3)

* remove 'only' from Main Post Input accessibility test (#34799)

---------

Co-authored-by: Pablo Vélez <pablovv2012@gmail.com>
2025-12-19 08:42:22 +02:00
Mattermost Build
e260ac346f
[MM-66889] Load scheduled posts for team in thread popout (#34766) (#34800)
Some checks failed
Server CI / Compute Go Version (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Server CI / Check mocks (push) Has been cancelled
Server CI / Check go mod tidy (push) Has been cancelled
Server CI / check-style (push) Has been cancelled
Server CI / Check serialization methods for hot structs (push) Has been cancelled
Server CI / Vet API (push) Has been cancelled
Server CI / Check migration files (push) Has been cancelled
Server CI / Generate email templates (push) Has been cancelled
Server CI / Check store layers (push) Has been cancelled
Server CI / Check mmctl docs (push) Has been cancelled
Server CI / Postgres with binary parameters (push) Has been cancelled
Server CI / Postgres (push) Has been cancelled
Server CI / Postgres (FIPS) (push) Has been cancelled
Server CI / Generate Test Coverage (push) Has been cancelled
Server CI / Run mmctl tests (push) Has been cancelled
Server CI / Run mmctl tests (FIPS) (push) Has been cancelled
Server CI / Build mattermost server app (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
(cherry picked from commit 402b70e645)

Co-authored-by: Devin Binnie <52460000+devinbinnie@users.noreply.github.com>
2025-12-18 20:06:33 +00:00
Mattermost Build
6f9b21c68e
enforce InviteUser permission for team invite settings (#34715) (#34794)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Automatic Merge
2025-12-18 15:01:23 +02:00
Mattermost Build
8567622504
MM-66939 -Hide plugin actions menu for burn-on-read posts (#34729) (#34798)
Automatic Merge
2025-12-18 14:31:23 +02:00
Mattermost Build
378804d0df
MM-66907 - hide bor ui elements with prof or ent licenses (#34787) (#34792)
Automatic Merge
2025-12-18 13:31:23 +02:00
Mattermost Build
95b9160472
Update prepackaged Agents to 1.7.2 (#34780) (#34790)
Automatic Merge
2025-12-18 12:31:24 +02:00
Mattermost Build
2a83ca9646
fix build error (#34783) (#34788)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Automatic Merge
2025-12-18 10:31:27 +02:00
Mattermost Build
d5529dcc9a
[MM-66709] Avoid magic link login if already logged in (#34613) (#34776)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Automatic Merge
2025-12-17 20:31:23 +02:00
Mattermost Build
3bb469082e
[MM-66710] Do not allow MFA enforcement on magic link accounts (#34614) (#34779)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Automatic Merge
2025-12-17 19:31:25 +02:00
Mattermost Build
7cd53beea6
MM-66948: Filter expired BoR from flagged posts (#34744) (#34775)
Automatic Merge
2025-12-17 19:01:28 +02:00
Mattermost Build
a912d1177b
Filter burn on read posts from search results (#34747) (#34782)
(cherry picked from commit 1c68d36a03)

Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>
2025-12-17 15:50:56 +00:00
Mattermost Build
a5a0e18064
Fix an issue where files for BoR messages were not properly deleted (#34743) (#34773)
(cherry picked from commit 7dce49ee84)

Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>
2025-12-17 11:39:03 +01:00
Mattermost Build
c7bebc1c81
Update web app package versions to 11.3.0 (#34750) (#34767)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
(cherry picked from commit 5fe3987e91)

Co-authored-by: Harrison Healey <harrisonmhealey@gmail.com>
2025-12-16 19:52:49 +00:00
Mattermost Build
9cf621f640
MM-66424: Improve team filtering in common teams API (#34454) (#34761)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Automatic Merge
2025-12-16 10:24:16 +02:00
Mattermost Build
a73f912669
Update Zoom prepackaged version to 1.11.0 (#34734) (#34742)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Automatic Merge
2025-12-15 10:54:17 +02:00
Mattermost Build
f5385514df
MM-66943: Fix SavePluginConfig wiping other plugins' configs (#34733) (#34741)
Automatic Merge
2025-12-15 10:24:16 +02:00
Mattermost Build
636486dc56
[MM-66799] Remove magic link users password (#34616) (#34735)
Automatic Merge
2025-12-15 08:54:16 +02:00
Mattermost Build
9dbe20f9ab
User id auth control (#34441) (#34731)
Some checks failed
Server CI / Compute Go Version (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Server CI / Check mocks (push) Has been cancelled
Server CI / Check go mod tidy (push) Has been cancelled
Server CI / check-style (push) Has been cancelled
Server CI / Check serialization methods for hot structs (push) Has been cancelled
Server CI / Vet API (push) Has been cancelled
Server CI / Check migration files (push) Has been cancelled
Server CI / Generate email templates (push) Has been cancelled
Server CI / Check store layers (push) Has been cancelled
Server CI / Check mmctl docs (push) Has been cancelled
Server CI / Postgres with binary parameters (push) Has been cancelled
Server CI / Postgres (push) Has been cancelled
Server CI / Postgres (FIPS) (push) Has been cancelled
Server CI / Generate Test Coverage (push) Has been cancelled
Server CI / Run mmctl tests (push) Has been cancelled
Server CI / Run mmctl tests (FIPS) (push) Has been cancelled
Server CI / Build mattermost server app (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
* Disabled user ID auth if email and username login are disabled

* Added tests

* lint fix

---------


(cherry picked from commit 61651b0df7)

Co-authored-by: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com>
2025-12-12 12:34:18 +02:00
Mattermost Build
682534dea1
Update Agents plugin to v1.7.1 (#34716) (#34730)
Automatic Merge
2025-12-12 09:54:15 +02:00
Mattermost Build
60c65c95b9
MM-65959: Add FIPS indicator to about dialog (#34463) (#34728)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
(cherry picked from commit a21169e2a8)

Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com>
2025-12-12 08:21:30 +02:00
Mattermost Build
0a2f0c4e81
MM-65960: Avoid replica race lag when accessing TelemetryID (#34586) (#34727)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
* avoid replica race lag when remembering ServerID

In an HA environment, with a master and read replica, querying the server id from the store runs the risk of returning a value saved to master but not yet replicated. Avoid this by using the telemetry service value directly when available.

Fixes: MM-65960

* Add Get(ByName)WithContext

* explicitly use master for ServerId

* mock GetByNameWithContext

* more mocking

* more mocks

(cherry picked from commit 6ef73af2cc)

Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com>
2025-12-11 22:01:46 +00:00
Mattermost Build
fcc81d962b
[MM-66875][MM-66876] Implement RHS plugin popout (#34692) (#34724)
* [MM-66875] Implement RHS popout component

* [MM-66876] Add RHS plugin popouts

* Give plugins a way to check when popouts are opened

* Fix test

* Fix border radius

* Fix lint

* Update title to just show plugin name

* Add server name to plugin popout for Desktop App

* Fix test

---------


(cherry picked from commit 959022f953)

Co-authored-by: Devin Binnie <52460000+devinbinnie@users.noreply.github.com>
2025-12-11 19:32:52 +00:00
Mattermost Build
5eb7b7acd8
[MM-66708] Disallow interacting with password and login method for magic link accounts (#34615) (#34720)
Automatic Merge
2025-12-11 20:24:16 +02:00
Mattermost Build
b3b1005f09
MM-66880 Fix Components package being included in build twice (#34690) (#34718)
Automatic Merge
2025-12-11 17:24:16 +02:00
Mattermost Build
b65dbf4434
[MM-61758] Burn on read feature (#34703) (#34710)
Some checks are pending
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
* Add read receipt store for burn on read message types

* update mocks

* fix invalidation target

* have consistent case on index creation

* Add temporary posts table

* add mock

* add transaction support

* reflect review comments

* wip: Add reveal endpoint

* user check error id instead

* wip: Add ws events and cleanup for burn on read posts

* add burn endpoint for explicitly burning messages

* add translations

* Added logic to associate files of BoR post with the post

* Added test

* fixes

* disable pinning posts and review comments

* MM-66594 - Burn on read UI integration (#34647)

* MM-66244 - add BoR visual components to message editor

* MM-66246 - BoR visual indicator for sender and receiver

* MM-66607 - bor - add timer countdown and autodeletion

* add the system console max time to live config

* use the max expire at and create global scheduler to register bor messages

* use seconds for BoR config values in BE

* implement the read by text shown in the tooltip logic

* unestack the posts from same receiver and BoR  and fix styling

* avoid opening reply RHS

* remove unused dispatchers

* persis the BoR label in the drafts

* move expiration value to metadata

* adjust unit tests to metadata insted of props

* code clean up and some performance improvements; add period grace for deletion too

* adjust migration serie number

* hide bor messages when config is off

* performance improvements on post component and code clean up

* keep bor existing post functionality if config is disabled

* Add read receipt store for burn on read message types

* Add temporary posts table

* add transaction support

* reflect review comments

* wip: Add reveal endpoint

* user check error id instead

* wip: Add ws events and cleanup for burn on read posts

* avoid reacting to unrevealed bor messages

* adjust migration number

* Add read receipt store for burn on read message types

* have consistent case on index creation

* Add temporary posts table

* add mock

* add transaction support

* reflect review comments

* wip: Add reveal endpoint

* user check error id instead

* wip: Add ws events and cleanup for burn on read posts

* add burn endpoint for explicitly burning messages

* adjust post reveal and type with backend changes

* use real config values, adjust icon usage and style

* adjust the delete from from sender and receiver

* improve self deleting logic by placing in badge, use burn endpoint

* adjust websocket events handling for the read by sender label information

* adjust styling for concealed and error state

* update burn-on-read post event handling for improved recipient tracking and multi-device sync

* replace burn_on_read with type in database migrations and model

* remove burn_on_read metadata from PostMetadata and related structures

* Added logic to associate files of BoR post with the post

* Added test

* adjust migration name and fix linter

* Add read receipt store for burn on read message types

* update mocks

* have consistent case on index creation

* Add temporary posts table

* add mock

* add transaction support

* reflect review comments

* wip: Add reveal endpoint

* user check error id instead

* wip: Add ws events and cleanup for burn on read posts

* add burn endpoint for explicitly burning messages

* Added logic to associate files of BoR post with the post

* Added test

* disable pinning posts and review comments

* show attachment on bor reveal

* remove unused translation

* Enhance burn-on-read post handling and refine previous post ID retrieval logic

* adjust the returning chunk to work with bor messages

* read temp post from master db

* read from master

* show the copy link button to the sender

* revert unnecessary check

* restore correct json tag

* remove unused error handling  and clarify burn-on-read comment

* improve type safety and use proper selectors

* eliminate code duplication in deletion handler

* optimize performance and add documentation

* delete bor message for sender once all receivers reveal it

* add burn on read to scheduled posts

* add feature enable check

* use master to avoid  all read recipients race condition

---------





* squash migrations into single file

* add configuration for the scheduler

* don't run messagehasbeenposted hook

* remove parallel tests on burn on read

* add clean up for closing opened modals from previous tests

* simplify delete menu item rendering

* add cleanup step to close open modals after each test to prevent pollution

* streamline delete button visibility logic for Burn on Read posts

* improve reliability of closing post menu and modals by using body ESC key

---------




(cherry picked from commit 084006c0ea)

Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>
Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Pablo Vélez <pablovv2012@gmail.com>
2025-12-11 12:40:21 +01:00
Mattermost Build
b7bf0ec9f6
Fix build order issue in TS types (#34711) (#34714)
(cherry picked from commit c519789529)

Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>
2025-12-11 11:25:15 +01:00
271 changed files with 13207 additions and 1854 deletions

View file

@ -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.

View file

@ -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:

View file

@ -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

View file

@ -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();
});

View file

@ -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

View file

@ -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',

View file

@ -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');

View file

@ -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', () => {

View file

@ -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)', () => {

View file

@ -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');

View file

@ -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();
});
};
});

View file

@ -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();

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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'});

View file

@ -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

View file

@ -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)
}

View file

@ -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

View 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)
})
}

View file

@ -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.

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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)
}

View file

@ -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)
})
}

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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))
}

View file

@ -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)

View file

@ -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)
})
})
}

View file

@ -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 {

View file

@ -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)
})
}

View file

@ -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"

View file

@ -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{

View file

@ -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

View file

@ -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)
})
}

View file

@ -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 {

View file

@ -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{}

View file

@ -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)
}

View file

@ -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")

View file

@ -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()

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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())
})
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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",

View file

@ -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")

View file

@ -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 {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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;

View file

@ -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;

View 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)
}

View 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)
}

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View 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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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}

View file

@ -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
}

View file

@ -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))

View file

@ -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 {

View file

@ -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").

View file

@ -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)

View file

@ -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().

View 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
}

View 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)
}

View file

@ -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,
}
}

View file

@ -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$

View file

@ -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))
}

View 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
}

View 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)
}

View file

@ -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.

View file

@ -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{

View file

@ -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")
})
}

View 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
}

View 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
}

View file

@ -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()

View file

@ -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)

View 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
}

View 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)
}
}

View file

@ -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,
)
}

View 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])
})
}

View file

@ -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}

View file

@ -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)

View file

@ -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