MM-59966 - Compliance Export overhaul - feature branch (#29789)

* [MM-59089] Add a compliance export constant (#27919)

* add a useful constant

* i18n

* another constant

* another i18n

* [MM-60422] Add GetChannelsWithActivityDuring (#28301)

* modify GetUsersInChannelDuring to accept a slice of channelIds

* add GetChannelsWithActivityDuring

* add compliance export progress message; remove unused custom status

* linting

* tests running too fast

* add batch size config settings

* add store tests

* linting

* empty commit

* i18n changes

* fix i18n ordering

* MM-60570 - Server-side changes consolidating the export CLI with server/ent code (#28640)

* add an i18n field; add the CLI's export directory

* int64 -> int

* Add UntilUpdateAt for MessageExport and AnalyticsPostCount

to merge

* remove now-unused i18n strings

* add TranslationsPreInitFromBuffer to allow CLI to use i18n

* use GetBuilder to simplify; rename TranslationsPreInitFromFileBytes

* [MM-59089] Improve compliance export timings (#1733 - Enterprise repo)

* MM-60422 - Performance and logic fixes for Compliance Exports (#1757 - Enterprise repo)

* MM-60570 - Enterprise-side changes consolidating the export CLI with server/ent code (#1769 - Enterprise repo)

* merge conflicts; missed file from ent branch

* MM-61038 - Add an option to sqlstore.New (#28702)

remove useless comment

add test

add an option to sqlstore.New

* MM-60976: Remove RunExport command from Mattermost binary (#28805)

* remove RunExport command from mattermost binary

* remove the code it was calling

* fix i18n

* remove test (was only testing license, not functionality)

* empty commit

* fix flaky GetChannelsWithActivityDuring test

* MM-60063: Dedicated Export Filestore fix, redo of #1772 (enterprise) (#28803)

* redo filestore fix #1772 (enterprise repo) on top of MM-59966 feature

* add new e2e tests for export filestore

* golint

* ok, note to self: shadowing bad, actually (when there's a defer)

* empty commit

* MM-61137 - Message export: Support 7.8.11 era dbs (#28824)

* support 7.8.11 era dbs by wrapping the store using only what we need

* fix flaky GetChannelsWithActivityDuring test

* add a comment

* only need to define the MEFileInfoStore (the one that'll be overridden)

* blank commit

* MM-60974 - Message Export: Add performance metrics (#28836)

* support 7.8.11 era dbs by wrapping the store using only what we need

* fix flaky GetChannelsWithActivityDuring test

* add a comment

* only need to define the MEFileInfoStore (the one that'll be overridden)

* performance metrics

* cleanup unneeded named returns

* blank commit

* MM-60975 - Message export: Add startTime and endTime to export folder name (#28840)

* support 7.8.11 era dbs by wrapping the store using only what we need

* fix flaky GetChannelsWithActivityDuring test

* add a comment

* only need to define the MEFileInfoStore (the one that'll be overridden)

* performance metrics

* output startTime and endTime in export folder

* empty commit

* merge conflict

* MM-60978 - Message export: Improve xml fields; fix delete semantics (#28873)

* support 7.8.11 era dbs by wrapping the store using only what we need

* fix flaky GetChannelsWithActivityDuring test

* add a comment

* only need to define the MEFileInfoStore (the one that'll be overridden)

* performance metrics

* output startTime and endTime in export folder

* empty commit

* add xml fields, omit when empty, tests

* fix delete semantics; test (and test for update semantics)

* clarify comments

* simplify edited post detection, now there's no edge case.

* add some spacing to help fast running tests

* merge conflicts/updates needed for new deleted post semantics

* linting; fixing tests from upstream merge

* use SafeDereference

* linting

* stronger typing; better wrapped errors; better formatting

* blank commit

* goimports formatting

* fix merge mistake

* minor fixes due to changes in master

* MM-61755 - Simplifying and Support reporting to the db from the CLI (#29281)

* finally clean up JobData struct and stringMap; prep for CLI using db

* and now simplify using StringMapToJobDataWithZeroValues

* remove unused fn

* create JobDataExported; clean up errors

* MM-60176 - Message Export: Global relay cleanup (#29168)

* move global relay logic into global_relay_export

* blank commit

* blank commit

* improve errors

* MM-60693 - Refactor CSV to use same codepath as Actiance (#29191)

* move global relay logic into global_relay_export

* blank commit

* refactor (and simplify) ExportParams into shared

* blank commit

* remove unused fn

* csv now uses pre-calculated joins/leaves like actiance

* improve errors

* remove nil post check; remove ignoredPosts metric

* remove unneeded copy

* MM-61696 - Refactor GlobalRelay to use same codepath as Actiance (#29225)

* move global relay logic into global_relay_export

* blank commit

* refactor (and simplify) ExportParams into shared

* blank commit

* remove unused fn

* csv now uses pre-calculated joins/leaves like actiance

* remove newly unneeded function and its test. goodbye.

* refactor GetPostAttachments for csv + global relay to share

* refactor global_relay_export and fix tests (no changes to output)

* improve errors

* remove nil post check; remove ignoredPosts metric

* remove unneeded copy

* remove unneeded nil check

* PR comments

* MM-61715 - Generalize e2e to all export types 🤖  (#29369)

* move global relay logic into global_relay_export

* blank commit

* refactor (and simplify) ExportParams into shared

* blank commit

* remove unused fn

* csv now uses pre-calculated joins/leaves like actiance

* remove newly unneeded function and its test. goodbye.

* refactor GetPostAttachments for csv + global relay to share

* refactor global_relay_export and fix tests (no changes to output)

* improve errors

* remove nil post check; remove ignoredPosts metric

* remove unneeded copy

* remove unneeded nil check

* PR comments

* refactor isDeletedMsg for all export types

* fix start and endtime, nasty csv createAt bug; bring closer to Actiance

* align unit tests with new logic (e.g. starttime / endtime)

* refactor a TimestampConvert fn for code + tests

* bug: pass templates to global relay (hurray for e2e tests, otherwise...)

* add global relay zip to allowed list (only for tests)

* test helpers

* new templates for e2e tests

* e2e tests... phew.

* linting

* merge conflicts

* unexport PostToRow; add test helper marker

* cleanup, shortening, thanks to PR comments

* MM-61972 - Generalize export data path - Actiance (#29399)

* extract and generalize the export data generation functions

* finish moving test (bc of previous extraction)

* lift a function from common -> shared (to break an import cycle)

* actiance now takes general export data, processes it into actiance data

* bring tests in line with correct sorting rules (upadateAt, messageId)

* fixups, PR comments

* turn strings.Repeat into a more descriptive const

amended: one letter fix; bad rebase

* MM-62009 - e2e clock heisenbug (#29434)

* consolidate assertions; output debuggable diffs (keeping for future)

* refactor test output generator to generators file

* waitUntilZeroPosts + pass through until to job = fix all clock issues

* simplify messages to model.NewId(); remove unneeded waitUntilZeroPosts

* model.NewId() -> storetest.NewTestID()

* MM-61980 - Generalize export data path - CSV (#29482)

* simple refactoring

* increase sleep times for (very) rare test failures

* add extra information to the generic export for CSV

* adj Actiance to handle new generic export (no difference in its output)

* no longer need mergePosts (yay), move getJoinLeavePosts for everyone

* adjust tests for new csv semantics (detailed in summary)

* and need to add the new exported data to the export_data_tests

* rearrange csv writing to happen after data export (more logical)

* linting

* remove debug statements

* figured out what was wrong with global relay e2e test 3; solid now

* PR comments

* MM-61718 - Generalize export data path - Global Relay (#29508)

* move global relay over to using the generalized export data

* performance pass -- not much can be done

* Update server/enterprise/message_export/global_relay_export/global_relay_export.go

Co-authored-by: Claudio Costa <cstcld91@gmail.com>

---------

Co-authored-by: Claudio Costa <cstcld91@gmail.com>

* MM-62058 - Align CSV with Actiance (#29551)

* refactoring actiance files and var names for clarity

* bug found in exported attachments (we used to miss some start/ends)

* changes needed for actiance due to new generic exports

* bringing CSV up to actiance standards

* fixing global relay b/c of new semantics (adding a note on an edge case)

* aligning e2e tests, adding comments to clarify what is expected/tested

* necessary changes; 1 more test for added functionality (ignoreDeleted)

* comment style

* MM-62059 - Align Global Relay with Actiance/CSV; many fixes (#29665)

* core logic changes to general export_data and the specific export paths

* unit tests and e2e tests, covering all new edge cases and all logic

* linting

* better var naming, const value, and cleaning up functions calls

* MM-62436 - Temporarily skip cypress tests that require download link (#29772)

---------

Co-authored-by: Claudio Costa <cstcld91@gmail.com>
Этот коммит содержится в:
Christopher Poile
2025-01-10 16:56:02 -05:00
коммит произвёл GitHub
родитель 96195f1bd7
Коммит aba4434dab
103 изменённых файлов: 14696 добавлений и 3091 удалений

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

@@ -44,7 +44,7 @@ describe('Compliance Export', () => {
});
});
it('MM-T3435 - Download Compliance Export Files - CSV Format', () => {
it.skip('MM-60115 - MM-T3435 - Download Compliance Export Files - CSV Format', () => {
// # Navigate to a team and post an attachment
cy.visit(`/${teamName}/channels/town-square`);
gotoTeamAndPostImage();
@@ -65,7 +65,7 @@ describe('Compliance Export', () => {
});
});
it('MM-T3438 - Download Compliance Export Files when 0 messages exported', () => {
it.skip('MM-60115 - MM-T3438 - Download Compliance Export Files when 0 messages exported', () => {
// # Navigate to a team and post an attachment
cy.visit(`/${teamName}/channels/town-square`);
gotoTeamAndPostImage();
@@ -94,7 +94,7 @@ describe('Compliance Export', () => {
});
});
it('MM-T1168 - Compliance Export - Run Now, entry appears in job table', () => {
it.skip('MM-60115 - MM-T1168 - Compliance Export - Run Now, entry appears in job table', () => {
// # Navigate to a team and post an attachment
cy.visit(`/${teamName}/channels/town-square`);
gotoTeamAndPostImage();

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

@@ -60,7 +60,7 @@ describe('Compliance Export', () => {
cy.shellRm('-rf', downloadsFolder);
});
it('MM-T1175_1 - UserType identifies that the message is posted by a bot', () => {
it.skip('MM-60115 - MM-T1175_1 - UserType identifies that the message is posted by a bot', () => {
const message = `This is CSV bot message from ${botName} at ${Date.now()}`;
// # Post bot message
@@ -81,7 +81,7 @@ describe('Compliance Export', () => {
);
});
it('MM-T1175_2 - UserType identifies that the message is posted by a bot', () => {
it.skip('MM-60115 - MM-T1175_2 - UserType identifies that the message is posted by a bot', () => {
const message = `This is XML bot message from ${botName} at ${Date.now()}`;
// # Post bot message

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

@@ -52,7 +52,7 @@ describe('Compliance Export', () => {
cy.shellRm('-rf', downloadsFolder);
});
it('MM-T1172 - Compliance Export - Deleted file is indicated in CSV File Export', () => {
it.skip('MM-60115 - MM-T1172 - Compliance Export - Deleted file is indicated in CSV File Export', () => {
// # Go to compliance page and enable export
cy.uiGoToCompliancePage();
cy.uiEnableComplianceExport();
@@ -84,7 +84,7 @@ describe('Compliance Export', () => {
);
});
it('MM-T1173 - Compliance Export - Deleted file is indicated in Actiance XML File Export', () => {
it.skip('MM-60115 - MM-T1173 - Compliance Export - Deleted file is indicated in Actiance XML File Export', () => {
// # Go to compliance page and enable export
cy.uiGoToCompliancePage();
cy.uiEnableComplianceExport(ExportFormatActiance);
@@ -121,7 +121,7 @@ describe('Compliance Export', () => {
});
});
it('MM-T1176 - Compliance export should include updated post after editing', () => {
it.skip('MM-60115 - MM-T1176 - Compliance export should include updated post after editing', () => {
// # Go to compliance page and enable export
cy.uiGoToCompliancePage();
cy.uiEnableComplianceExport(ExportFormatActiance);
@@ -154,7 +154,7 @@ describe('Compliance Export', () => {
);
});
it('MM-T3305 - Verify Deactivated users are displayed properly in Compliance Exports', () => {
it.skip('MM-60115 - MM-T3305 - Verify Deactivated users are displayed properly in Compliance Exports', () => {
// # Post a message by Admin
cy.postMessageAs({
sender: adminUser,

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

@@ -298,7 +298,7 @@ func TestJoinDefaultChannelsCreatesChannelMemberHistoryRecordTownSquare(t *testi
channel, err := th.App.Srv().Store().Channel().GetByName(th.BasicTeam.Id, "town-square", true)
require.NoError(t, err)
townSquareChannelID := channel.Id
users, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, townSquareChannelID)
users, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{townSquareChannelID})
require.NoError(t, nErr)
initialNumTownSquareUsers := len(users)
@@ -308,7 +308,7 @@ func TestJoinDefaultChannelsCreatesChannelMemberHistoryRecordTownSquare(t *testi
require.Nil(t, appErr)
// there should be a ChannelMemberHistory record for the user
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, townSquareChannelID)
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{townSquareChannelID})
require.NoError(t, nErr)
assert.Len(t, histories, initialNumTownSquareUsers+1)
@@ -330,7 +330,7 @@ func TestJoinDefaultChannelsCreatesChannelMemberHistoryRecordOffTopic(t *testing
channel, err := th.App.Srv().Store().Channel().GetByName(th.BasicTeam.Id, "off-topic", true)
require.NoError(t, err)
offTopicChannelId := channel.Id
users, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, offTopicChannelId)
users, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{offTopicChannelId})
require.NoError(t, nErr)
initialNumTownSquareUsers := len(users)
@@ -340,7 +340,7 @@ func TestJoinDefaultChannelsCreatesChannelMemberHistoryRecordOffTopic(t *testing
require.Nil(t, appError)
// there should be a ChannelMemberHistory record for the user
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, offTopicChannelId)
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{offTopicChannelId})
require.NoError(t, nErr)
assert.Len(t, histories, initialNumTownSquareUsers+1)
@@ -418,7 +418,7 @@ func TestCreateChannelPublicCreatesChannelMemberHistoryRecord(t *testing.T) {
publicChannel := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
// there should be a ChannelMemberHistory record for the user
histories, err := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, publicChannel.Id)
histories, err := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{publicChannel.Id})
require.NoError(t, err)
assert.Len(t, histories, 1)
assert.Equal(t, th.BasicUser.Id, histories[0].UserId)
@@ -433,7 +433,7 @@ func TestCreateChannelPrivateCreatesChannelMemberHistoryRecord(t *testing.T) {
privateChannel := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypePrivate)
// there should be a ChannelMemberHistory record for the user
histories, err := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, privateChannel.Id)
histories, err := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{privateChannel.Id})
require.NoError(t, err)
assert.Len(t, histories, 1)
assert.Equal(t, th.BasicUser.Id, histories[0].UserId)
@@ -557,7 +557,7 @@ func TestCreateGroupChannelCreatesChannelMemberHistoryRecord(t *testing.T) {
channel, appErr := th.App.CreateGroupChannel(th.Context, groupUserIds, th.BasicUser.Id)
require.Nil(t, appErr, "Failed to create group channel.")
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{channel.Id})
require.NoError(t, nErr)
assert.Len(t, histories, 3)
@@ -582,7 +582,7 @@ func TestCreateDirectChannelCreatesChannelMemberHistoryRecord(t *testing.T) {
channel, appErr := th.App.GetOrCreateDirectChannel(th.Context, user1.Id, user2.Id)
require.Nil(t, appErr, "Failed to create direct channel.")
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{channel.Id})
require.NoError(t, nErr)
assert.Len(t, histories, 2)
@@ -610,7 +610,7 @@ func TestGetDirectChannelCreatesChannelMemberHistoryRecord(t *testing.T) {
require.Nil(t, appErr, "Failed to create direct channel.")
// there should be a ChannelMemberHistory record for both users
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{channel.Id})
require.NoError(t, nErr)
assert.Len(t, histories, 2)
@@ -645,7 +645,7 @@ func TestAddUserToChannelCreatesChannelMemberHistoryRecord(t *testing.T) {
require.Nil(t, appErr, "Failed to add user to channel.")
// there should be a ChannelMemberHistory record for the user
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{channel.Id})
require.NoError(t, nErr)
assert.Len(t, histories, 2)
channelMemberHistoryUserIds := make([]string, 0)
@@ -656,6 +656,88 @@ func TestAddUserToChannelCreatesChannelMemberHistoryRecord(t *testing.T) {
assert.Equal(t, groupUserIds, channelMemberHistoryUserIds)
}
func TestUsersAndPostsCreateActivityInChannel(t *testing.T) {
th := Setup(t).InitBasic().DeleteBots()
defer th.TearDown()
user := th.CreateUser()
_, err := th.App.AddTeamMember(th.Context, th.BasicTeam.Id, user.Id)
require.Nil(t, err, "Failed to add user to team.")
user3 := th.CreateUser()
_, err = th.App.AddTeamMember(th.Context, th.BasicTeam.Id, user3.Id)
require.Nil(t, err, "Failed to add user to team.")
user4 := th.CreateUser()
_, err = th.App.AddTeamMember(th.Context, th.BasicTeam.Id, user4.Id)
require.Nil(t, err, "Failed to add user to team.")
channel1 := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
channel2 := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
channel3 := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
channel4 := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
channel5 := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
channel6 := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
// user3 is already in channel3
_, err = th.App.AddUserToChannel(th.Context, user3, channel3, false)
require.Nil(t, err, "Failed to add user to channel.")
// user4 is already in channel4 (for the second part of the test)
_, err = th.App.AddUserToChannel(th.Context, user4, channel4, false)
require.Nil(t, err, "Failed to add user to channel.")
// make sure we don't catch earlier posts
time.Sleep(10 * time.Millisecond)
testStart := model.GetMillis()
// Test: previous activity (user3 and 4's adds) aren't showing up:
channelIds, nErr := th.App.Srv().Store().ChannelMemberHistory().GetChannelsWithActivityDuring(testStart, testStart+10000)
require.NoError(t, nErr)
assert.Len(t, channelIds, 0)
// Posts, adds, and leaves should create activity
post := &model.Post{
ChannelId: channel1.Id,
Message: "root post",
UserId: th.BasicUser.Id,
}
_, err = th.App.CreatePost(th.Context, post, channel1, model.CreatePostFlags{})
require.Nil(t, err, "Failed to create post.")
_, err = th.App.AddUserToChannel(th.Context, user, channel2, false)
require.Nil(t, err, "Failed to add user to channel.")
err = th.App.RemoveUserFromChannel(th.Context, user3.Id, user3.Id, channel3)
require.Nil(t, err, "Failed to add user to channel.")
// Test: there should be a ChannelMemberHistory record for the users and the post
channelIds, nErr = th.App.Srv().Store().ChannelMemberHistory().GetChannelsWithActivityDuring(testStart, model.GetMillis())
require.NoError(t, nErr)
assert.Len(t, channelIds, 3)
assert.ElementsMatch(t, []string{channel1.Id, channel2.Id, channel3.Id}, channelIds)
testEnd := model.GetMillis()
// In case the tests are running very fast:
time.Sleep(10 * time.Millisecond)
// Now, we do not find activity for new posts, leaves, or adds after the test is over
post2 := &model.Post{
ChannelId: channel5.Id,
Message: "root post",
UserId: th.BasicUser.Id,
}
err = th.App.RemoveUserFromChannel(th.Context, user4.Id, user4.Id, channel4)
require.Nil(t, err, "Failed to create post.")
_, err = th.App.CreatePost(th.Context, post2, channel5, model.CreatePostFlags{})
require.Nil(t, err, "Failed to create post.")
_, err = th.App.AddUserToChannel(th.Context, user, channel6, false)
require.Nil(t, err, "Failed to add user to channel.")
// Test: we get the same three channels as before, not channels 4, 5, 6 which have activity after testEnd
channelIds, nErr = th.App.Srv().Store().ChannelMemberHistory().GetChannelsWithActivityDuring(testStart, testEnd)
require.NoError(t, nErr)
assert.Len(t, channelIds, 3)
assert.ElementsMatch(t, []string{channel1.Id, channel2.Id, channel3.Id}, channelIds)
}
func TestLeaveDefaultChannel(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
@@ -811,7 +893,7 @@ func TestAddChannelMemberNoUserRequestor(t *testing.T) {
require.Nil(t, appErr, "Failed to add user to channel.")
// there should be a ChannelMemberHistory record for the user
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)
histories, nErr := th.App.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, []string{channel.Id})
require.NoError(t, nErr)
assert.Len(t, histories, 2)
channelMemberHistoryUserIds := make([]string, 0)

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

@@ -103,7 +103,7 @@ func (a *App) TestFileStoreConnectionWithConfig(cfg *model.FileSettings) *model.
backend, err = filestore.NewFileBackend(filestore.NewFileBackendSettingsFromConfig(cfg, complianceEnabled, insecure != nil && *insecure))
}
if err != nil {
return model.NewAppError("FileBackend", "api.file.no_driver.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return model.NewAppError("FileAttachmentBackend", "api.file.no_driver.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
nErr := backend.TestConnection()
if nErr != nil {

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

@@ -2933,7 +2933,25 @@ func (s *OpenTracingLayerChannelMemberHistoryStore) GetChannelsLeftSince(userID
return result, err
}
func (s *OpenTracingLayerChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelID string) ([]*model.ChannelMemberHistoryResult, error) {
func (s *OpenTracingLayerChannelMemberHistoryStore) GetChannelsWithActivityDuring(startTime int64, endTime int64) ([]string, error) {
origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelMemberHistoryStore.GetChannelsWithActivityDuring")
s.Root.Store.SetContext(newCtx)
defer func() {
s.Root.Store.SetContext(origCtx)
}()
defer span.Finish()
result, err := s.ChannelMemberHistoryStore.GetChannelsWithActivityDuring(startTime, endTime)
if err != nil {
span.LogFields(spanlog.Error(err))
ext.Error.Set(span, true)
}
return result, err
}
func (s *OpenTracingLayerChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelID []string) ([]*model.ChannelMemberHistoryResult, error) {
origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelMemberHistoryStore.GetUsersInChannelDuring")
s.Root.Store.SetContext(newCtx)

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

@@ -3270,7 +3270,28 @@ func (s *RetryLayerChannelMemberHistoryStore) GetChannelsLeftSince(userID string
}
func (s *RetryLayerChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelID string) ([]*model.ChannelMemberHistoryResult, error) {
func (s *RetryLayerChannelMemberHistoryStore) GetChannelsWithActivityDuring(startTime int64, endTime int64) ([]string, error) {
tries := 0
for {
result, err := s.ChannelMemberHistoryStore.GetChannelsWithActivityDuring(startTime, endTime)
if err == nil {
return result, nil
}
if !isRepeatableError(err) {
return result, err
}
tries++
if tries >= 3 {
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
return result, err
}
timepkg.Sleep(100 * timepkg.Millisecond)
}
}
func (s *RetryLayerChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelID []string) ([]*model.ChannelMemberHistoryResult, error) {
tries := 0
for {

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

@@ -65,27 +65,78 @@ func (s SqlChannelMemberHistoryStore) LogLeaveEvent(userId string, channelId str
return nil
}
func (s SqlChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistoryResult, error) {
func (s SqlChannelMemberHistoryStore) GetChannelsWithActivityDuring(startTime int64, endTime int64) ([]string, error) {
// ChannelMemberHistory has been in production for long enough that we are assuming the export period
// starts after the ChannelMemberHistory table was first introduced
subqueryPosts := s.getSubQueryBuilder().
Select("p.ChannelId").
Distinct().
From("Posts AS p").
Where(
sq.And{
sq.GtOrEq{"p.UpdateAt": startTime},
sq.LtOrEq{"p.UpdateAt": endTime},
sq.NotLike{"p.Type": "system_%"},
})
subqueryCMH := s.getSubQueryBuilder().
Select("cmh.ChannelId").
Distinct().
From("ChannelMemberHistory AS cmh").
Where(
sq.Or{
sq.And{
sq.GtOrEq{"cmh.JoinTime": startTime},
sq.LtOrEq{"cmh.JoinTime": endTime},
},
sq.And{
sq.GtOrEq{"cmh.LeaveTime": startTime},
sq.LtOrEq{"cmh.LeaveTime": endTime},
},
})
unionExpr, args, err := sq.Expr("(? UNION ?) AS cm", subqueryPosts, subqueryCMH).ToSql()
if err != nil {
return nil, errors.Wrap(err, "GetChannelsWithActivityDuring unionExpr to sql")
}
// no bound args in this expression
query, _, err := s.getQueryBuilder().
Select("*").
From(unionExpr).ToSql()
if err != nil {
return nil, errors.Wrap(err, "GetChannelsWithActivityDuring query to sql")
}
channelIds := make([]string, 0)
if err := s.GetReplica().Select(&channelIds, query, args...); err != nil {
return nil, err
}
return channelIds, nil
}
func (s SqlChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelIds []string) ([]*model.ChannelMemberHistoryResult, error) {
useChannelMemberHistory, err := s.hasDataAtOrBefore(startTime)
if err != nil {
return nil, errors.Wrapf(err, "hasDataAtOrBefore startTime=%d endTime=%d channelId=%s", startTime, endTime, channelId)
return nil, errors.Wrapf(err, "hasDataAtOrBefore startTime=%d endTime=%d channelId=%v", startTime, endTime, channelIds)
}
if useChannelMemberHistory {
// the export period starts after the ChannelMemberHistory table was first introduced, so we can use the
// data from it for our export
channelMemberHistories, err2 := s.getFromChannelMemberHistoryTable(startTime, endTime, channelId)
channelMemberHistories, err2 := s.getFromChannelMemberHistoryTable(startTime, endTime, channelIds)
if err2 != nil {
return nil, errors.Wrapf(err2, "getFromChannelMemberHistoryTable startTime=%d endTime=%d channelId=%s", startTime, endTime, channelId)
return nil, errors.Wrapf(err2, "getFromChannelMemberHistoryTable startTime=%d endTime=%d channelId=%v", startTime, endTime, channelIds)
}
return channelMemberHistories, nil
}
// the export period starts before the ChannelMemberHistory table was introduced, so we need to fake the
// data by assuming that anybody who has ever joined the channel in question was present during the export period.
// this may not always be true, but it's better than saying that somebody wasn't there when they were
channelMemberHistories, err := s.getFromChannelMembersTable(startTime, endTime, channelId)
channelMemberHistories, err := s.getFromChannelMembersTable(startTime, endTime, channelIds)
if err != nil {
return nil, errors.Wrapf(err, "getFromChannelMembersTable startTime=%d endTime=%d channelId=%s", startTime, endTime, channelId)
return nil, errors.Wrapf(err, "getFromChannelMembersTable startTime=%d endTime=%d channelId=%v", startTime, endTime, channelIds)
}
return channelMemberHistories, nil
}
@@ -108,14 +159,14 @@ func (s SqlChannelMemberHistoryStore) hasDataAtOrBefore(time int64) (bool, error
return false, nil
}
func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistoryResult, error) {
func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime int64, endTime int64, channelIds []string) ([]*model.ChannelMemberHistoryResult, error) {
query, args, err := s.getQueryBuilder().
Select(`cmh.*, u.Email AS "Email", u.Username, Bots.UserId IS NOT NULL AS IsBot, u.DeleteAt AS UserDeleteAt`).
From("ChannelMemberHistory cmh").
Join("Users u ON cmh.UserId = u.Id").
LeftJoin("Bots ON Bots.UserId = u.Id").
Where(sq.And{
sq.Eq{"cmh.ChannelId": channelId},
sq.Eq{"cmh.ChannelId": channelIds},
sq.LtOrEq{"cmh.JoinTime": endTime},
sq.Or{
sq.Eq{"cmh.LeaveTime": nil},
@@ -134,14 +185,14 @@ func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime
return histories, nil
}
func (s SqlChannelMemberHistoryStore) getFromChannelMembersTable(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistoryResult, error) {
func (s SqlChannelMemberHistoryStore) getFromChannelMembersTable(startTime int64, endTime int64, channelIds []string) ([]*model.ChannelMemberHistoryResult, error) {
query, args, err := s.getQueryBuilder().
Select(`ch.ChannelId, ch.UserId, u.Email AS "Email", u.Username, Bots.UserId IS NOT NULL AS IsBot, u.DeleteAt AS UserDeleteAt`).
Distinct().
From("ChannelMembers ch").
Join("Users u ON ch.UserId = u.id").
LeftJoin("Bots ON Bots.UserId = u.id").
Where(sq.Eq{"ch.ChannelId": channelId}).ToSql()
Where(sq.Eq{"ch.ChannelId": channelIds}).ToSql()
if err != nil {
return nil, errors.Wrap(err, "channel_member_history_to_sql")
}

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

@@ -286,24 +286,25 @@ func (s SqlComplianceStore) MessageExport(c request.CTX, cursor model.MessageExp
return nil, cursor, errors.Wrap(caseErr, "unable to construct case statement")
}
query, args, err := s.getQueryBuilder().Select(`Posts.Id AS PostId,
Posts.CreateAt AS PostCreateAt,
Posts.UpdateAt AS PostUpdateAt,
Posts.DeleteAt AS PostDeleteAt,
Posts.Message AS PostMessage,
Posts.Type AS PostType,
Posts.Props AS PostProps,
Posts.OriginalId AS PostOriginalId,
Posts.RootId AS PostRootId,
Posts.FileIds AS PostFileIds,
Teams.Id AS TeamId,
Teams.Name AS TeamName,
builder := s.getQueryBuilder().Select(`Posts.Id AS PostId,
Posts.CreateAt AS PostCreateAt,
Posts.UpdateAt AS PostUpdateAt,
Posts.DeleteAt AS PostDeleteAt,
Posts.EditAt AS PostEditAt,
Posts.Message AS PostMessage,
Posts.Type AS PostType,
Posts.Props AS PostProps,
Posts.OriginalId AS PostOriginalId,
Posts.RootId AS PostRootId,
Posts.FileIds AS PostFileIds,
Teams.Id AS TeamId,
Teams.Name AS TeamName,
Teams.DisplayName AS TeamDisplayName,
Channels.Id AS ChannelId,
Channels.Name AS ChannelName,
Channels.Type AS ChannelType,
Users.Id AS UserId,
Users.Email AS UserEmail,
Channels.Id AS ChannelId,
Channels.Name AS ChannelName,
Channels.Type AS ChannelType,
Users.Id AS UserId,
Users.Email AS UserEmail,
Users.Username,
Bots.UserId IS NOT NULL AS IsBot`).
Column(caseStmt+" AS ChannelDisplayName", caseArgs...).
@@ -323,8 +324,13 @@ func (s SqlComplianceStore) MessageExport(c request.CTX, cursor model.MessageExp
sq.NotLike{"Posts.Type": "system_%"},
}).
OrderBy("PostUpdateAt, PostId").
Limit(uint64(limit)).
ToSql()
Limit(uint64(limit))
if cursor.UntilUpdateAt > 0 {
builder = builder.Where(sq.LtOrEq{"Posts.UpdateAt": cursor.UntilUpdateAt})
}
query, args, err := builder.ToSql()
if err != nil {
return nil, cursor, errors.Wrap(err, "unable to construct query to export messages")
}

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

@@ -2404,15 +2404,14 @@ func (s *SqlPostStore) AnalyticsPostCount(options *model.PostCountOptions) (int6
})
}
queryString, args, err := query.ToSql()
if err != nil {
return 0, errors.Wrap(err, "post_tosql")
if options.UntilUpdateAt > 0 {
query = query.Where(sq.LtOrEq{"p.UpdateAt": options.UntilUpdateAt})
}
var v int64
err = s.GetReplica().Get(&v, queryString, args...)
err := s.GetReplica().GetBuilder(&v, query)
if err != nil {
return 0, errors.Wrap(err, "failed to count Posts")
return 0, fmt.Errorf("post_tosql failed or failed to count Posts: %w", err)
}
return v, nil

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

@@ -35,6 +35,8 @@ import (
type migrationDirection string
type Option func(s *SqlStore) error
const (
IndexTypeFullText = "full_text"
IndexTypeFullTextFunc = "full_text_func"
@@ -139,12 +141,20 @@ type SqlStore struct {
isBinaryParam bool
pgDefaultTextSearchConfig string
skipMigrations bool
quitMonitor chan struct{}
wgMonitor *sync.WaitGroup
}
func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterfaces.MetricsInterface) (*SqlStore, error) {
func SkipMigrations() Option {
return func(s *SqlStore) error {
s.skipMigrations = true
return nil
}
}
func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterfaces.MetricsInterface, options ...Option) (*SqlStore, error) {
store := &SqlStore{
rrCounter: 0,
srCounter: 0,
@@ -155,6 +165,12 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface
wgMonitor: &sync.WaitGroup{},
}
for _, option := range options {
if err := option(store); err != nil {
return nil, fmt.Errorf("failed to apply option: %w", err)
}
}
err := store.initConnection()
if err != nil {
return nil, errors.Wrap(err, "error setting up connections")
@@ -178,9 +194,11 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface
return nil, errors.Wrap(err, "error while checking DB collation")
}
err = store.migrate(migrationsDirectionUp, false)
if err != nil {
return nil, errors.Wrap(err, "failed to apply database migrations")
if !store.skipMigrations {
err = store.migrate(migrationsDirectionUp, false)
if err != nil {
return nil, errors.Wrap(err, "failed to apply database migrations")
}
}
store.isBinaryParam, err = store.computeBinaryParam()

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

@@ -4,6 +4,7 @@
package sqlstore
import (
"database/sql"
"fmt"
"os"
"path/filepath"
@@ -977,3 +978,40 @@ func TestGetAppliedMigrations(t *testing.T) {
})
}
}
func TestSkipMigrationsOption(t *testing.T) {
testDrivers := []string{
model.DatabaseDriverPostgres,
model.DatabaseDriverMysql,
}
logger := mlog.CreateConsoleTestLogger(t)
for _, d := range testDrivers {
driver := d
t.Run("Should not apply migrations when skipMigrations=true for "+driver, func(t *testing.T) {
t.Parallel()
settings, err := makeSqlSettings(driver)
if err != nil {
t.Skip(err)
}
store, err := New(*settings, logger, nil, SkipMigrations())
require.NoError(t, err)
_, err = store.GetDBSchemaVersion()
assert.Error(t, err)
// Unfortunately there's no error for table doesn't exist, but we know it won't be ErrNoRows:
assert.True(t, !errors.Is(err, sql.ErrNoRows))
// And we know what each db will return:
if driver == model.DatabaseDriverPostgres {
assert.Contains(t, err.Error(), "pq: relation \"db_migrations\" does not exist")
} else {
// Mysql includes the random db name, so test the end:
assert.Contains(t, err.Error(), ".db_migrations' doesn't exist")
}
})
}
}

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

@@ -318,7 +318,8 @@ type ChannelStore interface {
type ChannelMemberHistoryStore interface {
LogJoinEvent(userID string, channelID string, joinTime int64) error
LogLeaveEvent(userID string, channelID string, leaveTime int64) error
GetUsersInChannelDuring(startTime int64, endTime int64, channelID string) ([]*model.ChannelMemberHistoryResult, error)
GetUsersInChannelDuring(startTime int64, endTime int64, channelID []string) ([]*model.ChannelMemberHistoryResult, error)
GetChannelsWithActivityDuring(startTime int64, endTime int64) ([]string, error)
PermanentDeleteBatchForRetentionPolicies(now, globalPolicyEndTime, limit int64, cursor model.RetentionPolicyCursor) (int64, model.RetentionPolicyCursor, error)
DeleteOrphanedRows(limit int) (deleted int64, err error)
PermanentDeleteBatch(endTime int64, limit int64) (int64, error)

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

@@ -4,9 +4,9 @@
package storetest
import (
"testing"
"math"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -21,6 +21,7 @@ func TestChannelMemberHistoryStore(t *testing.T, rctx request.CTX, ss store.Stor
t.Run("TestLogLeaveEvent", func(t *testing.T) { testLogLeaveEvent(t, rctx, ss) })
t.Run("TestGetUsersInChannelAtChannelMemberHistory", func(t *testing.T) { testGetUsersInChannelAtChannelMemberHistory(t, rctx, ss) })
t.Run("TestGetUsersInChannelAtChannelMembers", func(t *testing.T) { testGetUsersInChannelAtChannelMembers(t, rctx, ss) })
t.Run("TestGetChannelsWithActivityDuring", func(t *testing.T) { testGetChannelsWithActivityDuring(t, rctx, ss) })
t.Run("TestPermanentDeleteBatch", func(t *testing.T) { testPermanentDeleteBatch(t, rctx, ss) })
t.Run("TestPermanentDeleteBatchForRetentionPolicies", func(t *testing.T) { testPermanentDeleteBatchForRetentionPolicies(t, rctx, ss) })
t.Run("TestGetChannelsLeftSince", func(t *testing.T) { testGetChannelsLeftSince(t, rctx, ss) })
@@ -81,6 +82,160 @@ func testLogLeaveEvent(t *testing.T, rctx request.CTX, ss store.Store) {
assert.NoError(t, err)
}
func testGetChannelsWithActivityDuring(t *testing.T, rctx request.CTX, ss store.Store) {
// Need to wait to make sure channels and posts have nothing in them for this test.
time.Sleep(101 * time.Millisecond)
// create three test channels
ch1 := &model.Channel{
TeamId: model.NewId(),
DisplayName: "Display " + model.NewId(),
Name: model.NewId(),
Type: model.ChannelTypeOpen,
}
channel1, err := ss.Channel().Save(rctx, ch1, -1)
require.NoError(t, err)
// channel2 will have no activity until case 6 (shouldn't show up until then)
ch2 := &model.Channel{
TeamId: model.NewId(),
DisplayName: "Display " + model.NewId(),
Name: model.NewId(),
Type: model.ChannelTypeOpen,
}
channel2, err := ss.Channel().Save(rctx, ch2, -1)
require.NoError(t, err)
// and two test users
user1 := model.User{
Email: MakeEmail(),
Nickname: model.NewId(),
Username: model.NewUsername(),
}
userPtr, err := ss.User().Save(rctx, &user1)
require.NoError(t, err)
user1 = *userPtr
user2 := model.User{
Email: MakeEmail(),
Nickname: model.NewId(),
Username: model.NewUsername(),
}
userPtr, err = ss.User().Save(rctx, &user2)
require.NoError(t, err)
user2 = *userPtr
now := model.GetMillis()
originalNow := now
// user2 joins channel2 before test
err = ss.ChannelMemberHistory().LogJoinEvent(user2.Id, channel2.Id, now-2000)
require.NoError(t, err)
// case 7: assert no activity for time period before channel activity
channelIds, err := ss.ChannelMemberHistory().GetChannelsWithActivityDuring(originalNow-100, originalNow)
require.NoError(t, err)
assert.Empty(t, channelIds)
// case 1: user1 was in channel before period, doesn't show activity
err = ss.ChannelMemberHistory().LogJoinEvent(user1.Id, channel1.Id, now-1100)
require.NoError(t, err)
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now, now+1000)
require.NoError(t, err)
assert.Empty(t, channelIds)
// case 2: user1 leaves, shows activity
err = ss.ChannelMemberHistory().LogLeaveEvent(user1.Id, channel1.Id, now+1)
require.NoError(t, err)
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now, now+1000)
require.NoError(t, err)
assert.Equal(t, channelIds, []string{channel1.Id})
// case 3: user1 joins, shows activity
err = ss.ChannelMemberHistory().LogJoinEvent(user1.Id, channel1.Id, now+2)
require.NoError(t, err)
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now+2, now+1000)
require.NoError(t, err)
assert.Equal(t, channelIds, []string{channel1.Id})
// case 4: new post shows activity
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now+3, now+1000)
require.NoError(t, err)
assert.Empty(t, channelIds)
post := &model.Post{
ChannelId: channel1.Id,
Message: "root post",
UserId: user1.Id,
CreateAt: now + 4,
UpdateAt: now + 4,
}
post, err = ss.Post().Save(rctx, post)
require.NoError(t, err)
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now+3, now+1000)
require.NoError(t, err)
assert.Equal(t, channelIds, []string{channel1.Id})
// case 5: update shows activity
// need to wait because update uses getMillis
time.Sleep(10 * time.Millisecond)
now = model.GetMillis()
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now-1, now+1000)
require.NoError(t, err)
assert.Empty(t, channelIds)
newPost := post.Clone()
newPost.Message = "edited message"
_, err = ss.Post().Update(rctx, newPost, post)
require.NoError(t, err)
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now-1, now+1000)
require.NoError(t, err)
assert.Equal(t, channelIds, []string{channel1.Id})
// case 6: get both activity from posts and from join/leave;
// - also, sql deduplicates two channel1 results (from post and channel history tables)
time.Sleep(1 * time.Millisecond)
now = model.GetMillis()
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now, now+1000)
require.NoError(t, err)
assert.Empty(t, channelIds)
post2 := &model.Post{
ChannelId: channel1.Id,
Message: "root post",
UserId: user1.Id,
CreateAt: now + 11,
UpdateAt: now + 11,
}
_, err = ss.Post().Save(rctx, post2)
require.NoError(t, err)
err = ss.ChannelMemberHistory().LogLeaveEvent(user1.Id, channel1.Id, now+12)
require.NoError(t, err)
err = ss.ChannelMemberHistory().LogLeaveEvent(user2.Id, channel2.Id, now+13)
require.NoError(t, err)
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now+10, now+1000)
require.NoError(t, err)
assert.ElementsMatch(t, channelIds, []string{channel1.Id, channel2.Id})
// case 7: still no activity for period before tests
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(originalNow-100, originalNow)
require.NoError(t, err)
assert.Empty(t, channelIds)
// case 8: no activity for period after tests
channelIds, err = ss.ChannelMemberHistory().GetChannelsWithActivityDuring(now+100, now+1000)
require.NoError(t, err)
assert.Empty(t, channelIds)
}
func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, rctx request.CTX, ss store.Store) {
// create a test channel
ch := &model.Channel{
@@ -119,12 +274,12 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, rctx request.CTX,
require.NoError(t, err)
// case 1: user joins and leaves the channel before the export period begins
channelMembers, err := ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, channel.Id)
channelMembers, err := ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, []string{channel.Id})
require.NoError(t, err)
assert.Empty(t, channelMembers)
// case 2: user joins the channel after the export period begins, but has not yet left the channel when the export period ends
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -135,7 +290,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, rctx request.CTX,
assert.Nil(t, channelMembers[0].LeaveTime)
// case 3: user joins the channel before the export period begins, but has not yet left the channel when the export period ends
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -150,7 +305,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, rctx request.CTX,
require.NoError(t, err)
// case 4: user joins the channel before the export period begins, but has not yet left the channel when the export period ends
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -161,7 +316,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, rctx request.CTX,
assert.Equal(t, leaveTime, *channelMembers[0].LeaveTime)
// case 5: user joins the channel after the export period begins, and leaves the channel before the export period ends
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -172,7 +327,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, rctx request.CTX,
assert.Equal(t, leaveTime, *channelMembers[0].LeaveTime)
// case 6: user has joined and left the channel long before the export period begins
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, []string{channel.Id})
require.NoError(t, err)
assert.Empty(t, channelMembers)
}
@@ -223,7 +378,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, rctx request.CTX, ss st
// the past, even though the time that they were actually in the channel doesn't necessarily overlap with the export period
// case 1: user joins and leaves the channel before the export period begins
channelMembers, err := ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, channel.Id)
channelMembers, err := ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -234,7 +389,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, rctx request.CTX, ss st
assert.Equal(t, joinTime-100, *channelMembers[0].LeaveTime)
// case 2: user joins the channel after the export period begins, but has not yet left the channel when the export period ends
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -245,7 +400,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, rctx request.CTX, ss st
assert.Equal(t, joinTime+500, *channelMembers[0].LeaveTime)
// case 3: user joins the channel before the export period begins, but has not yet left the channel when the export period ends
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -256,7 +411,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, rctx request.CTX, ss st
assert.Equal(t, joinTime+500, *channelMembers[0].LeaveTime)
// case 4: user joins the channel before the export period begins, but has not yet left the channel when the export period ends
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -267,7 +422,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, rctx request.CTX, ss st
assert.Equal(t, leaveTime-100, *channelMembers[0].LeaveTime)
// case 5: user joins the channel after the export period begins, and leaves the channel before the export period ends
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -278,7 +433,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, rctx request.CTX, ss st
assert.Equal(t, leaveTime+100, *channelMembers[0].LeaveTime)
// case 6: user has joined and left the channel long before the export period begins
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, channel.Id, channelMembers[0].ChannelId)
@@ -332,7 +487,7 @@ func testPermanentDeleteBatch(t *testing.T, rctx request.CTX, ss store.Store) {
require.NoError(t, err)
// in between the join time and the leave time, both users were members of the channel
channelMembers, err := ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+10, leaveTime-10, channel.Id)
channelMembers, err := ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+10, leaveTime-10, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 2)
@@ -343,7 +498,7 @@ func testPermanentDeleteBatch(t *testing.T, rctx request.CTX, ss store.Store) {
assert.NotEqual(t, int64(0), rowsDeleted)
// after the delete, there should be one less member in the channel
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+10, leaveTime-10, channel.Id)
channelMembers, err = ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+10, leaveTime-10, []string{channel.Id})
require.NoError(t, err)
assert.Len(t, channelMembers, 1)
assert.Equal(t, user2.Id, channelMembers[0].UserId)
@@ -387,7 +542,7 @@ func testPermanentDeleteBatchForRetentionPolicies(t *testing.T, rctx request.CTX
_, _, err = ss.ChannelMemberHistory().PermanentDeleteBatchForRetentionPolicies(
nowMillis, 0, limit, model.RetentionPolicyCursor{})
require.NoError(t, err)
result, err := ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime, leaveTime, channel.Id)
result, err := ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime, leaveTime, []string{channel.Id})
require.NoError(t, err)
require.Empty(t, result, "history should have been deleted by channel policy")
rows, err := ss.RetentionPolicy().GetIdsForDeletionByTableName("ChannelMemberHistory", 1000)

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

@@ -58,6 +58,7 @@ func TestComplianceStore(t *testing.T, rctx request.CTX, ss store.Store) {
t.Run("MessageEditAfterExportMessage", func(t *testing.T) { testEditAfterExportMessage(t, rctx, ss) })
t.Run("MessageDeleteExportMessage", func(t *testing.T) { testDeleteExportMessage(t, rctx, ss) })
t.Run("MessageDeleteAfterExportMessage", func(t *testing.T) { testDeleteAfterExportMessage(t, rctx, ss) })
t.Run("MessageExport_UntilUpdateAt", func(t *testing.T) { testMessageExportUntilUpdateAt(t, rctx, ss) })
}
func testComplianceStore(t *testing.T, rctx request.CTX, ss store.Store) {
@@ -1174,3 +1175,105 @@ func testDeleteAfterExportMessage(t *testing.T, rctx request.CTX, ss store.Store
assert.Equal(t, user1.Email, *v.UserEmail)
assert.Equal(t, user1.Username, *v.Username)
}
func testMessageExportUntilUpdateAt(t *testing.T, rctx request.CTX, ss store.Store) {
defer cleanupStoreState(t, rctx, ss)
// get the starting number of message export entries
startTime := model.GetMillis()
messages, _, err := ss.Compliance().MessageExport(rctx, model.MessageExportCursor{LastPostUpdateAt: startTime - 10}, 10)
require.NoError(t, err)
assert.Equal(t, 0, len(messages))
// need a team
team := &model.Team{
DisplayName: "DisplayName",
Name: model.NewId(),
Email: MakeEmail(),
Type: model.TeamOpen,
}
team, err = ss.Team().Save(team)
require.NoError(t, err)
// and two users that are a part of that team
user1 := &model.User{
Email: MakeEmail(),
Username: model.NewUsername(),
}
user1, err = ss.User().Save(rctx, user1)
require.NoError(t, err)
_, nErr := ss.Team().SaveMember(rctx, &model.TeamMember{
TeamId: team.Id,
UserId: user1.Id,
}, -1)
require.NoError(t, nErr)
user2 := &model.User{
Email: MakeEmail(),
Username: model.NewUsername(),
}
user2, err = ss.User().Save(rctx, user2)
require.NoError(t, err)
_, nErr = ss.Team().SaveMember(rctx, &model.TeamMember{
TeamId: team.Id,
UserId: user2.Id,
}, -1)
require.NoError(t, nErr)
// need a public channel
channel := &model.Channel{
TeamId: team.Id,
Name: model.NewId(),
DisplayName: "Public Channel",
Type: model.ChannelTypeOpen,
}
channel, nErr = ss.Channel().Save(rctx, channel, -1)
require.NoError(t, nErr)
var posts []*model.Post
// user1 posts ten times in the public channel
for i := 0; i < 10; i++ {
post := &model.Post{
ChannelId: channel.Id,
UserId: user1.Id,
CreateAt: startTime + int64(i),
UpdateAt: startTime + int64(i),
Message: model.NewId(),
}
post, err = ss.Post().Save(rctx, post)
require.NoError(t, err)
posts = append(posts, post)
}
// fetch 5 starting from the third post, using LastPostUpdateAt and UntilUpdateAt.
// UntilUpdateAt is inclusive
messageExportMap := map[string]model.MessageExport{}
messages, _, err = ss.Compliance().MessageExport(rctx, model.MessageExportCursor{LastPostUpdateAt: posts[2].UpdateAt, UntilUpdateAt: posts[2].UpdateAt + 4}, 10000)
require.NoError(t, err)
assert.Equal(t, 5, len(messages))
for _, v := range messages {
messageExportMap[*v.PostId] = *v
}
for i := 2; i < 7; i++ {
assert.Equal(t, posts[i].Id, *messageExportMap[posts[i].Id].PostId)
assert.Equal(t, posts[i].CreateAt, *messageExportMap[posts[i].Id].PostCreateAt)
assert.Equal(t, posts[i].Message, *messageExportMap[posts[i].Id].PostMessage)
assert.Equal(t, channel.Id, *messageExportMap[posts[i].Id].ChannelId)
assert.Equal(t, channel.DisplayName, *messageExportMap[posts[i].Id].ChannelDisplayName)
assert.Equal(t, user1.Id, *messageExportMap[posts[i].Id].UserId)
assert.Equal(t, user1.Email, *messageExportMap[posts[i].Id].UserEmail)
assert.Equal(t, user1.Username, *messageExportMap[posts[i].Id].Username)
}
// Also test AnalyticsPostCount because they are used in tandem for MessageExports
count, err := ss.Post().AnalyticsPostCount(&model.PostCountOptions{
TeamId: channel.TeamId,
ExcludeSystemPosts: true,
SinceUpdateAt: posts[2].UpdateAt,
UntilUpdateAt: posts[2].UpdateAt + 4,
})
require.NoError(t, err)
require.Equal(t, 5, int(count))
}

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

@@ -72,8 +72,38 @@ func (_m *ChannelMemberHistoryStore) GetChannelsLeftSince(userID string, since i
return r0, r1
}
// GetChannelsWithActivityDuring provides a mock function with given fields: startTime, endTime
func (_m *ChannelMemberHistoryStore) GetChannelsWithActivityDuring(startTime int64, endTime int64) ([]string, error) {
ret := _m.Called(startTime, endTime)
if len(ret) == 0 {
panic("no return value specified for GetChannelsWithActivityDuring")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func(int64, int64) ([]string, error)); ok {
return rf(startTime, endTime)
}
if rf, ok := ret.Get(0).(func(int64, int64) []string); ok {
r0 = rf(startTime, endTime)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func(int64, int64) error); ok {
r1 = rf(startTime, endTime)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUsersInChannelDuring provides a mock function with given fields: startTime, endTime, channelID
func (_m *ChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelID string) ([]*model.ChannelMemberHistoryResult, error) {
func (_m *ChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelID []string) ([]*model.ChannelMemberHistoryResult, error) {
ret := _m.Called(startTime, endTime, channelID)
if len(ret) == 0 {
@@ -82,10 +112,10 @@ func (_m *ChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, en
var r0 []*model.ChannelMemberHistoryResult
var r1 error
if rf, ok := ret.Get(0).(func(int64, int64, string) ([]*model.ChannelMemberHistoryResult, error)); ok {
if rf, ok := ret.Get(0).(func(int64, int64, []string) ([]*model.ChannelMemberHistoryResult, error)); ok {
return rf(startTime, endTime, channelID)
}
if rf, ok := ret.Get(0).(func(int64, int64, string) []*model.ChannelMemberHistoryResult); ok {
if rf, ok := ret.Get(0).(func(int64, int64, []string) []*model.ChannelMemberHistoryResult); ok {
r0 = rf(startTime, endTime, channelID)
} else {
if ret.Get(0) != nil {
@@ -93,7 +123,7 @@ func (_m *ChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, en
}
}
if rf, ok := ret.Get(1).(func(int64, int64, string) error); ok {
if rf, ok := ret.Get(1).(func(int64, int64, []string) error); ok {
r1 = rf(startTime, endTime, channelID)
} else {
r1 = ret.Error(1)

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

@@ -2696,7 +2696,23 @@ func (s *TimerLayerChannelMemberHistoryStore) GetChannelsLeftSince(userID string
return result, err
}
func (s *TimerLayerChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelID string) ([]*model.ChannelMemberHistoryResult, error) {
func (s *TimerLayerChannelMemberHistoryStore) GetChannelsWithActivityDuring(startTime int64, endTime int64) ([]string, error) {
start := time.Now()
result, err := s.ChannelMemberHistoryStore.GetChannelsWithActivityDuring(startTime, endTime)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("ChannelMemberHistoryStore.GetChannelsWithActivityDuring", success, elapsed)
}
return result, err
}
func (s *TimerLayerChannelMemberHistoryStore) GetUsersInChannelDuring(startTime int64, endTime int64, channelID []string) ([]*model.ChannelMemberHistoryResult, error) {
start := time.Now()
result, err := s.ChannelMemberHistoryStore.GetUsersInChannelDuring(startTime, endTime, channelID)

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

@@ -5,7 +5,6 @@ package commands
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
@@ -33,30 +32,6 @@ var ScheduleExportCmd = &cobra.Command{
RunE: scheduleExportCmdF,
}
var CsvExportCmd = &cobra.Command{
Use: "csv",
Short: "Export data from Mattermost in CSV format",
Long: "Export data from Mattermost in CSV format",
Example: "export csv --exportFrom=12345",
RunE: buildExportCmdF("csv"),
}
var ActianceExportCmd = &cobra.Command{
Use: "actiance",
Short: "Export data from Mattermost in Actiance format",
Long: "Export data from Mattermost in Actiance format",
Example: "export actiance --exportFrom=12345",
RunE: buildExportCmdF("actiance"),
}
var GlobalRelayZipExportCmd = &cobra.Command{
Use: "global-relay-zip",
Short: "Export data from Mattermost into a zip file containing emails to send to Global Relay for debug and testing purposes only.",
Long: "Export data from Mattermost into a zip file containing emails to send to Global Relay for debug and testing purposes only. This does not archive any information in Global Relay.",
Example: "export global-relay-zip --exportFrom=12345",
RunE: buildExportCmdF("globalrelay-zip"),
}
var BulkExportCmd = &cobra.Command{
Use: "bulk [file]",
Short: "Export bulk data.",
@@ -71,15 +46,6 @@ func init() {
ScheduleExportCmd.Flags().Int64("exportFrom", -1, "The timestamp of the earliest post to export, expressed in seconds since the unix epoch.")
ScheduleExportCmd.Flags().Int("timeoutSeconds", -1, "The maximum number of seconds to wait for the job to complete before timing out.")
CsvExportCmd.Flags().Int64("exportFrom", -1, "The timestamp of the earliest post to export, expressed in seconds since the unix epoch.")
CsvExportCmd.Flags().Int("limit", -1, "The number of posts to export. The default of -1 means no limit.")
ActianceExportCmd.Flags().Int64("exportFrom", -1, "The timestamp of the earliest post to export, expressed in seconds since the unix epoch.")
ActianceExportCmd.Flags().Int("limit", -1, "The number of posts to export. The default of -1 means no limit.")
GlobalRelayZipExportCmd.Flags().Int64("exportFrom", -1, "The timestamp of the earliest post to export, expressed in seconds since the unix epoch.")
GlobalRelayZipExportCmd.Flags().Int("limit", -1, "The number of posts to export. The default of -1 means no limit.")
BulkExportCmd.Flags().Bool("all-teams", true, "Export all teams from the server.")
BulkExportCmd.Flags().Bool("with-archived-channels", false, "Also exports archived channels.")
BulkExportCmd.Flags().Bool("with-profile-pictures", false, "Also exports profile pictures.")
@@ -87,9 +53,6 @@ func init() {
BulkExportCmd.Flags().Bool("archive", false, "Outputs a single archive file.")
ExportCmd.AddCommand(ScheduleExportCmd)
ExportCmd.AddCommand(CsvExportCmd)
ExportCmd.AddCommand(ActianceExportCmd)
ExportCmd.AddCommand(GlobalRelayZipExportCmd)
ExportCmd.AddCommand(BulkExportCmd)
RootCmd.AddCommand(ExportCmd)
@@ -158,57 +121,6 @@ func scheduleExportCmdF(command *cobra.Command, args []string) error {
return nil
}
func buildExportCmdF(format string) func(command *cobra.Command, args []string) error {
return func(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.SkipPostInitialization())
license := a.Srv().License()
if err != nil {
return err
}
defer a.Srv().Shutdown()
rctx := request.EmptyContext(a.Log())
startTime, err := command.Flags().GetInt64("exportFrom")
if err != nil {
return errors.New("exportFrom flag error")
}
if startTime < 0 {
return errors.New("exportFrom must be a positive integer")
}
limit, err := command.Flags().GetInt("limit")
if err != nil {
return errors.New("limit flag error")
}
if a.MessageExport() == nil || license == nil || !*license.Features.MessageExport {
return errors.New("message export feature not available")
}
warningsCount, appErr := a.MessageExport().RunExport(rctx, format, startTime, limit)
if appErr != nil {
return appErr
}
if warningsCount == 0 {
CommandPrettyPrintln("SUCCESS: Your data was exported.")
} else {
if format == model.ComplianceExportTypeGlobalrelay || format == model.ComplianceExportTypeGlobalrelayZip {
CommandPrettyPrintln(fmt.Sprintf("WARNING: %d warnings encountered, see logs for details.", warningsCount))
} else {
CommandPrettyPrintln(fmt.Sprintf("WARNING: %d warnings encountered, see warning.txt for details.", warningsCount))
}
}
auditRec := a.MakeAuditRecord(rctx, "buildExport", audit.Success)
auditRec.AddMeta("format", format)
auditRec.AddMeta("start", startTime)
a.LogAuditRec(rctx, auditRec, nil)
return nil
}
}
func bulkExportCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.SkipPostInitialization())
if err != nil {

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

@@ -1,74 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// There are no tests that actually run the Message Export job, because it can take a long time to complete depending
// on the size of the database that the config is pointing to. As such, these tests just ensure that the CLI command
// fails fast if invalid flags are supplied
func TestMessageExportNotEnabled(t *testing.T) {
th := Setup(t)
defer th.TearDown()
config := th.Config()
config.MessageExportSettings.EnableExport = model.NewPointer(false)
th.SetConfig(config)
// should fail fast because the feature isn't enabled
require.Error(t, th.RunCommand(t, "export", "schedule"))
}
func TestMessageExportInvalidFormat(t *testing.T) {
th := Setup(t)
defer th.TearDown()
config := th.Config()
config.MessageExportSettings.EnableExport = model.NewPointer(true)
th.SetConfig(config)
// should fail fast because format isn't supported
require.Error(t, th.RunCommand(t, "--format", "not_actiance", "export", "schedule"))
}
func TestMessageExportNegativeExportFrom(t *testing.T) {
th := Setup(t)
defer th.TearDown()
config := th.Config()
config.MessageExportSettings.EnableExport = model.NewPointer(true)
th.SetConfig(config)
// should fail fast because export from must be a valid timestamp
require.Error(t, th.RunCommand(t, "--format", "actiance", "--exportFrom", "-1", "export", "schedule"))
}
func TestMessageExportNegativeTimeoutSeconds(t *testing.T) {
th := Setup(t)
defer th.TearDown()
config := th.Config()
config.MessageExportSettings.EnableExport = model.NewPointer(true)
th.SetConfig(config)
// should fail fast because timeout seconds must be a positive int
require.Error(t, th.RunCommand(t, "--format", "actiance", "--exportFrom", "0", "--timeoutSeconds", "-1", "export", "schedule"))
}
func TestMessageExportFailsWithoutLicense(t *testing.T) {
th := Setup(t)
defer th.TearDown()
actual, _ := th.RunCommandWithOutput(t, "export", "csv", "export.csv", "--exportFrom", "0")
// should fail fast because timeout seconds must be a positive int
require.Contains(t, actual, "message export feature not available")
}

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

@@ -9,6 +9,5 @@ import (
)
type MessageExportInterface interface {
StartSynchronizeJob(c request.CTX, exportFromTimestamp int64) (*model.Job, *model.AppError)
RunExport(c request.CTX, format string, since int64, limit int) (int64, *model.AppError)
StartSynchronizeJob(rctx request.CTX, exportFromTimestamp int64) (*model.Job, *model.AppError)
}

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

@@ -15,39 +15,9 @@ type MessageExportInterface struct {
mock.Mock
}
// RunExport provides a mock function with given fields: c, format, since, limit
func (_m *MessageExportInterface) RunExport(c request.CTX, format string, since int64, limit int) (int64, *model.AppError) {
ret := _m.Called(c, format, since, limit)
if len(ret) == 0 {
panic("no return value specified for RunExport")
}
var r0 int64
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, int64, int) (int64, *model.AppError)); ok {
return rf(c, format, since, limit)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, int64, int) int64); ok {
r0 = rf(c, format, since, limit)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(request.CTX, string, int64, int) *model.AppError); ok {
r1 = rf(c, format, since, limit)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// StartSynchronizeJob provides a mock function with given fields: c, exportFromTimestamp
func (_m *MessageExportInterface) StartSynchronizeJob(c request.CTX, exportFromTimestamp int64) (*model.Job, *model.AppError) {
ret := _m.Called(c, exportFromTimestamp)
// StartSynchronizeJob provides a mock function with given fields: rctx, exportFromTimestamp
func (_m *MessageExportInterface) StartSynchronizeJob(rctx request.CTX, exportFromTimestamp int64) (*model.Job, *model.AppError) {
ret := _m.Called(rctx, exportFromTimestamp)
if len(ret) == 0 {
panic("no return value specified for StartSynchronizeJob")
@@ -56,10 +26,10 @@ func (_m *MessageExportInterface) StartSynchronizeJob(c request.CTX, exportFromT
var r0 *model.Job
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, int64) (*model.Job, *model.AppError)); ok {
return rf(c, exportFromTimestamp)
return rf(rctx, exportFromTimestamp)
}
if rf, ok := ret.Get(0).(func(request.CTX, int64) *model.Job); ok {
r0 = rf(c, exportFromTimestamp)
r0 = rf(rctx, exportFromTimestamp)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Job)
@@ -67,7 +37,7 @@ func (_m *MessageExportInterface) StartSynchronizeJob(c request.CTX, exportFromT
}
if rf, ok := ret.Get(1).(func(request.CTX, int64) *model.AppError); ok {
r1 = rf(c, exportFromTimestamp)
r1 = rf(rctx, exportFromTimestamp)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)

23
server/enterprise/internal/file/utils.go Обычный файл
Просмотреть файл

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package file
import (
"os"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// DeleteTemp removes a file and logs the error
// Intended to be called in a defer after the creation of a temp file to ensure cleanup
func DeleteTemp(logger mlog.LoggerIFace, file *os.File) {
err := file.Close()
if err != nil {
logger.Warn("Failed to close temporary file", mlog.String("filename", file.Name()), mlog.Err(err))
}
err = os.Remove(file.Name())
if err != nil {
logger.Warn("Failed to delete temporary file", mlog.String("filename", file.Name()), mlog.Err(err))
}
}

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

@@ -4,29 +4,30 @@
package actiance_export
import (
"archive/zip"
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"path"
"sort"
"os"
"slices"
"strings"
"time"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/common_export"
"github.com/mattermost/mattermost/server/v8/enterprise/internal/file"
"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/enterprise/message_export/shared"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
const (
XMLNS = "http://www.w3.org/2001/XMLSchema-instance"
ActianceExportFilename = "actiance_export.xml"
ActianceWarningFilename = "warning.txt"
XMLNS = "http://www.w3.org/2001/XMLSchema-instance"
ActianceExportFilename = "actiance_export.xml"
ActianceWarningFilename = "warning.txt"
EnsureLastMessageInASort = "zzzzzzzzzzzzzzzzzzzzzzzzzz"
)
// The root-level element of an actiance export
@@ -39,17 +40,15 @@ type RootNode struct {
// The Conversation element indicates an ad hoc IM conversation or a group chat room.
// The messages from a persistent chat room are exported once a day so that a Conversation entry contains the messages posted to a chat room from 12:00:00 AM to 11:59:59 PM
type ChannelExport struct {
XMLName xml.Name `xml:"Conversation"`
Perspective string `xml:"Perspective,attr"` // the value of this attribute doesn't seem to matter. Using the channel name makes the export more human readable
ChannelId string `xml:"-"` // the unique id of the channel
RoomId string `xml:"RoomID"`
StartTime int64 `xml:"StartTimeUTC"` // utc timestamp (seconds), start of export period or create time of channel, whichever is greater. Example: 1366611728.
JoinEvents []JoinExport // start with a list of all users who were present in the channel during the export period
Elements []any
UploadStarts []*FileUploadStartExport
UploadStops []*FileUploadStopExport
LeaveEvents []LeaveExport // finish with a list of all users who were present in the channel during the export period
EndTime int64 `xml:"EndTimeUTC"` // utc timestamp (seconds), end of export period or delete time of channel, whichever is lesser. Example: 1366611728.
XMLName xml.Name `xml:"Conversation"`
Perspective string `xml:"Perspective,attr"` // the value of this attribute doesn't seem to matter. Using the channel name makes the export more human readable
ChannelId string `xml:"-"` // the unique id of the channel
RoomId string `xml:"RoomID"`
StartTime int64 `xml:"StartTimeUTC"` // utc timestamp (seconds), start of export period or create time of channel, whichever is greater. Example: 1366611728.
JoinEvents []JoinExport // start with a list of all users who were present in the channel during the export period
Elements []Sortable
LeaveEvents []LeaveExport // finish with a list of all users who were present in the channel during the export period
EndTime int64 `xml:"EndTimeUTC"` // utc timestamp (seconds), end of export period or delete time of channel, whichever is lesser. Example: 1366611728.
}
// The ParticipantEntered element indicates each user who participates in a conversation.
@@ -72,14 +71,55 @@ type LeaveExport struct {
CorporateEmailID string `xml:"CorporateEmailID"`
}
type Sortable interface {
SortVal() (int64, string)
}
func SortableSort(a, b Sortable) int {
aTimestamp, aId := a.SortVal()
bTimestamp, bId := b.SortVal()
if aTimestamp == bTimestamp {
return strings.Compare(aId, bId)
}
return int(aTimestamp - bTimestamp)
}
// The Message element indicates the message sent by a user
type PostExport struct {
XMLName xml.Name `xml:"Message"`
UserEmail string `xml:"LoginName"` // the email of the person that sent the post
UserType string `xml:"UserType"` // the type of the person that sent the post
PostTime int64 `xml:"DateTimeUTC"` // utc timestamp (seconds), time at which the user sent the post. Example: 1366611728
Message string `xml:"Content"` // the text body of the post
PreviewsPost string `xml:"PreviewsPost"` // the post id of the post that is previewed by the permalink preview feature
XMLName xml.Name `xml:"Message"`
MessageId string `xml:"MessageId"` // the message id in the db
UserEmail string `xml:"LoginName"` // the email of the person that sent the post
UserType shared.UserType `xml:"UserType"` // the type of the person that sent the post: "user" or "bot"
CreateAt int64 `xml:"DateTimeUTC"` // utc timestamp (unix milliseconds), the post's createAt
// Allows us to differentiate between:
// - "EditedOriginalMsg": the newly created message (new Id), which holds the pre-edited message contents. The
// "EditedNewMsgId" field will point to the message (original Id) which has the post-edited message content.
// - "EditedNewMsg": the post-edited message content. This is confusing, so be careful: in the db, this EditedNewMsg
// is actually the original messageId because we wanted an edited message to have the same messageId as the
// pre-edited message. But for the purposes of exporting and to keep the mental model clear for end-users, we are
// calling this the EditedNewMsg and EditedNewMsgId, because this will hold the NEW post-edited message contents,
// and that's what's important to the end-user viewing the export.
// - "UpdatedNoMsgChange": the message content hasn't changed, but the post was updated for some reason (reaction,
// replied-to, a reply was edited, a reply was deleted (as of 10.2), perhaps other reasons)
// - "Deleted": the message was deleted.
// - "FileDeleted": this message is recording that a file was deleted.
UpdatedType shared.PostUpdatedType `xml:"UpdatedType,omitempty"`
UpdateAt int64 `xml:"UpdatedDateTimeUTC,omitempty"` // if this is an updated post, this is the updated time (same as deleted time for deleted posts).
// when a message is edited, the EditedOriginalMsg points to the message Id that now has the newly edited message.
EditedNewMsgId string `xml:"EditedNewMsgId,omitempty"`
Message string `xml:"Content"` // the text body of the post
PreviewsPost string `xml:"PreviewsPost,omitempty"` // the post id of the post that is previewed by the permalink preview feature
}
func (p PostExport) SortVal() (int64, string) {
// updated messages are sorted by UpdateAt
if p.UpdatedType != "" {
return p.UpdateAt, p.MessageId
}
return p.CreateAt, p.MessageId
}
// The FileTransferStarted element indicates the beginning of a file transfer in a conversation
@@ -91,6 +131,11 @@ type FileUploadStartExport struct {
FilePath string `xml:"FileName"` // the path to the file, as stored on the server
}
func (f FileUploadStartExport) SortVal() (int64, string) {
// file messages should come after the message they were with
return f.UploadStartTime, EnsureLastMessageInASort
}
// The FileTransferEnded element indicates the end of a file transfer in a conversation
type FileUploadStopExport struct {
XMLName xml.Name `xml:"FileTransferEnded"`
@@ -101,73 +146,105 @@ type FileUploadStopExport struct {
Status string `xml:"Status"` // set to either "Completed" or "Failed" depending on the outcome of the upload operation
}
func ActianceExport(rctx request.CTX, posts []*model.MessageExport, db store.Store, exportBackend filestore.FileBackend, fileAttachmentBackend filestore.FileBackend, exportDirectory string) (warningCount int64, appErr *model.AppError) {
// sort the posts into buckets based on the channel in which they appeared
membersByChannel := common_export.MembersByChannel{}
metadata := common_export.Metadata{
Channels: map[string]common_export.MetadataChannel{},
MessagesCount: 0,
AttachmentsCount: 0,
StartTime: 0,
EndTime: 0,
func (f FileUploadStopExport) SortVal() (int64, string) {
// file messages should come after the message they were with
return f.UploadStopTime, EnsureLastMessageInASort
}
func ActianceExport(rctx request.CTX, p shared.ExportParams) (shared.RunExportResults, error) {
start := time.Now()
// Build the channel exports for the channels that had post or user join/leave activity this batch.
exportData, err := shared.GetGenericExportData(p)
if err != nil {
return exportData.Results, err
}
elementsByChannel := map[string][]any{}
allUploadedFiles := []*model.FileInfo{}
var allUploadedFiles []*model.FileInfo
for _, post := range posts {
if post == nil {
rctx.Logger().Warn("ignored a nil post reference in the list")
continue
// Convert the generic shared.ChannelExports to the Actiance-specific ChannelExports data.
channelExports := make([]ChannelExport, 0, len(exportData.Exports))
for _, channel := range exportData.Exports {
joinEvents := make([]JoinExport, 0, len(channel.JoinEvents))
leaveEvents := make([]LeaveExport, 0, len(channel.LeaveEvents))
for _, j := range channel.JoinEvents {
joinEvents = append(joinEvents, JoinExport{
UserEmail: j.UserEmail,
UserType: string(j.UserType),
JoinTime: j.JoinTime,
CorporateEmailID: j.UserEmail,
})
}
elementsByChannel[*post.ChannelId] = append(elementsByChannel[*post.ChannelId], postToExportEntry(post, post.PostCreateAt, *post.PostMessage))
if post.PostDeleteAt != nil && *post.PostDeleteAt > 0 && post.PostProps != nil {
props := map[string]any{}
if json.Unmarshal([]byte(*post.PostProps), &props) == nil {
if _, ok := props[model.PostPropsDeleteBy]; ok {
elementsByChannel[*post.ChannelId] = append(elementsByChannel[*post.ChannelId], postToExportEntry(post,
post.PostDeleteAt, "delete "+*post.PostMessage))
}
}
for _, l := range channel.LeaveEvents {
leaveEvents = append(leaveEvents, LeaveExport{
UserEmail: l.UserEmail,
UserType: string(l.UserType),
LeaveTime: l.LeaveTime,
CorporateEmailID: l.UserEmail,
})
}
startUploads, stopUploads, uploadedFiles, deleteFileMessages, err := postToAttachmentsEntries(post, db)
if err != nil {
return warningCount, err
elements := make([]Sortable, 0, len(channel.Posts)+len(channel.DeletedFiles)+len(channel.UploadStarts)+len(channel.UploadStops))
for _, p := range channel.Posts {
elements = append(elements, PostExport{
MessageId: *p.PostId,
UserEmail: *p.UserEmail,
UserType: p.UserType,
CreateAt: *p.PostCreateAt,
UpdatedType: p.UpdatedType,
UpdateAt: p.UpdateAt,
EditedNewMsgId: p.EditedNewMsgId,
Message: p.Message,
PreviewsPost: p.PreviewsPost,
})
}
elementsByChannel[*post.ChannelId] = append(elementsByChannel[*post.ChannelId], startUploads...)
elementsByChannel[*post.ChannelId] = append(elementsByChannel[*post.ChannelId], stopUploads...)
elementsByChannel[*post.ChannelId] = append(elementsByChannel[*post.ChannelId], deleteFileMessages...)
allUploadedFiles = append(allUploadedFiles, uploadedFiles...)
metadata.Update(post, len(uploadedFiles))
if _, ok := membersByChannel[*post.ChannelId]; !ok {
membersByChannel[*post.ChannelId] = common_export.ChannelMembers{}
for _, p := range channel.DeletedFiles {
elements = append(elements, PostExport{
MessageId: *p.PostId,
UserEmail: *p.UserEmail,
UserType: p.UserType,
CreateAt: *p.PostCreateAt,
UpdatedType: p.UpdatedType,
UpdateAt: p.UpdateAt,
EditedNewMsgId: p.EditedNewMsgId,
Message: p.Message,
PreviewsPost: p.PreviewsPost,
})
}
membersByChannel[*post.ChannelId][*post.UserId] = common_export.ChannelMember{
Email: *post.UserEmail,
UserId: *post.UserId,
IsBot: post.IsBot,
Username: *post.Username,
for _, u := range channel.UploadStarts {
elements = append(elements, FileUploadStartExport{
UserEmail: u.UserEmail,
UploadStartTime: u.UploadStartTime,
Filename: u.FileInfo.Name,
FilePath: u.FileInfo.Path,
})
}
}
rctx.Logger().Info("Exported data for channels", mlog.Int("number_of_channels", len(metadata.Channels)))
channelExports := []ChannelExport{}
for _, channel := range metadata.Channels {
channelExport, err := buildChannelExport(
channel,
membersByChannel[channel.ChannelId],
elementsByChannel[channel.ChannelId],
db,
)
if err != nil {
return warningCount, err
for _, u := range channel.UploadStops {
elements = append(elements, FileUploadStopExport{
UserEmail: u.UserEmail,
UploadStopTime: u.UploadStopTime,
Filename: u.FileInfo.Name,
FilePath: u.FileInfo.Path,
Status: u.Status,
})
}
channelExports = append(channelExports, *channelExport)
// We need to sort all the elements by (updateAt, messageId) because they were added by type above.
slices.SortStableFunc(elements, SortableSort)
channelExports = append(channelExports, ChannelExport{
Perspective: channel.DisplayName,
ChannelId: channel.ChannelId,
RoomId: fmt.Sprintf("%v - %v - %v", shared.ChannelTypeDisplayName(channel.ChannelType),
channel.ChannelName, channel.ChannelId),
StartTime: channel.StartTime,
JoinEvents: joinEvents,
Elements: elements,
LeaveEvents: leaveEvents,
EndTime: channel.EndTime,
})
allUploadedFiles = append(allUploadedFiles, channel.Files...)
}
export := &RootNode{
@@ -175,182 +252,113 @@ func ActianceExport(rctx request.CTX, posts []*model.MessageExport, db store.Sto
Channels: channelExports,
}
return writeExport(rctx, export, allUploadedFiles, exportDirectory, exportBackend, fileAttachmentBackend)
results := exportData.Results
results.ProcessingPostsMs = time.Since(start).Milliseconds()
results.WriteExportResult, err = writeExport(rctx, export, allUploadedFiles, p.ExportBackend, p.FileAttachmentBackend, p.BatchPath)
results.NumChannels = len(channelExports)
return results, err
}
func postToExportEntry(post *model.MessageExport, createTime *int64, message string) *PostExport {
userType := "user"
if post.IsBot {
userType = "bot"
}
return &PostExport{
PostTime: *createTime,
Message: message,
UserType: userType,
UserEmail: *post.UserEmail,
PreviewsPost: post.PreviewID(),
}
}
func postToAttachmentsEntries(post *model.MessageExport, db store.Store) ([]any, []any, []*model.FileInfo, []any, *model.AppError) {
// if the post included any files, we need to add special elements to the export.
if len(post.PostFileIds) == 0 {
return nil, nil, nil, nil, nil
}
fileInfos, err := db.FileInfo().GetForPost(*post.PostId, true, true, false)
if err != nil {
return nil, nil, nil, nil, model.NewAppError("postToAttachmentsEntries", "ent.message_export.actiance_export.get_attachment_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
startUploads := []any{}
stopUploads := []any{}
deleteFileMessages := []any{}
uploadedFiles := []*model.FileInfo{}
for _, fileInfo := range fileInfos {
// insert a record of the file upload into the export file
// path to exported file is relative to the xml file, so it's just the name of the exported file
startUploads = append(startUploads, &FileUploadStartExport{
UserEmail: *post.UserEmail,
Filename: fileInfo.Name,
FilePath: fileInfo.Path,
UploadStartTime: *post.PostCreateAt,
})
stopUploads = append(stopUploads, &FileUploadStopExport{
UserEmail: *post.UserEmail,
Filename: fileInfo.Name,
FilePath: fileInfo.Path,
UploadStopTime: *post.PostCreateAt,
Status: "Completed",
})
if fileInfo.DeleteAt > 0 && post.PostDeleteAt != nil {
deleteFileMessages = append(deleteFileMessages, postToExportEntry(post, post.PostDeleteAt, "delete "+fileInfo.Path))
}
uploadedFiles = append(uploadedFiles, fileInfo)
}
return startUploads, stopUploads, uploadedFiles, deleteFileMessages, nil
}
func buildChannelExport(channel common_export.MetadataChannel, members common_export.ChannelMembers, elements []any, db store.Store) (*ChannelExport, *model.AppError) {
channelExport := ChannelExport{
ChannelId: channel.ChannelId,
RoomId: fmt.Sprintf("%v - %v - %v", common_export.ChannelTypeDisplayName(channel.ChannelType), channel.ChannelName, channel.ChannelId),
StartTime: channel.StartTime,
EndTime: channel.EndTime,
Perspective: channel.ChannelDisplayName,
}
channelMembersHistory, err := db.ChannelMemberHistory().GetUsersInChannelDuring(channel.StartTime, channel.EndTime, channel.ChannelId)
if err != nil {
return nil, model.NewAppError("buildChannelExport", "ent.get_users_in_channel_during", nil, "", http.StatusInternalServerError).Wrap(err)
}
joins, leaves := common_export.GetJoinsAndLeavesForChannel(channel.StartTime, channel.EndTime, channelMembersHistory, members)
type StillJoinedInfo struct {
Time int64
Type string
}
stillJoined := map[string]StillJoinedInfo{}
for _, join := range joins {
userType := "user"
if join.IsBot {
userType = "bot"
}
channelExport.JoinEvents = append(channelExport.JoinEvents, JoinExport{
JoinTime: join.Datetime,
UserEmail: join.Email,
UserType: userType,
CorporateEmailID: join.Email,
})
if value, ok := stillJoined[join.Email]; !ok {
stillJoined[join.Email] = StillJoinedInfo{Time: join.Datetime, Type: userType}
} else {
if join.Datetime > value.Time {
stillJoined[join.Email] = StillJoinedInfo{Time: join.Datetime, Type: userType}
}
}
}
for _, leave := range leaves {
userType := "user"
if leave.IsBot {
userType = "bot"
}
channelExport.LeaveEvents = append(channelExport.LeaveEvents, LeaveExport{
LeaveTime: leave.Datetime,
UserEmail: leave.Email,
UserType: userType,
CorporateEmailID: leave.Email,
})
if leave.Datetime > stillJoined[leave.Email].Time {
delete(stillJoined, leave.Email)
}
}
for email := range stillJoined {
channelExport.LeaveEvents = append(channelExport.LeaveEvents, LeaveExport{
LeaveTime: channel.EndTime,
UserEmail: email,
UserType: stillJoined[email].Type,
CorporateEmailID: email,
})
}
sort.Slice(channelExport.LeaveEvents, func(i, j int) bool {
if channelExport.LeaveEvents[i].LeaveTime == channelExport.LeaveEvents[j].LeaveTime {
return channelExport.LeaveEvents[i].UserEmail < channelExport.LeaveEvents[j].UserEmail
}
return channelExport.LeaveEvents[i].LeaveTime < channelExport.LeaveEvents[j].LeaveTime
})
channelExport.Elements = elements
return &channelExport, nil
}
func writeExport(rctx request.CTX, export *RootNode, uploadedFiles []*model.FileInfo, exportDirectory string, exportBackend filestore.FileBackend, fileAttachmentBackend filestore.FileBackend) (warningCount int64, appErr *model.AppError) {
func writeExport(rctx request.CTX, export *RootNode, uploadedFiles []*model.FileInfo, exportBackend filestore.FileBackend, fileAttachmentBackend filestore.FileBackend, batchPath string) (res shared.WriteExportResult, err error) {
start := time.Now()
// marshal the export object to xml
xmlData := &bytes.Buffer{}
xmlData.WriteString(xml.Header)
enc := xml.NewEncoder(xmlData)
enc.Indent("", " ")
if err := enc.Encode(export); err != nil {
return warningCount, model.NewAppError("ActianceExport.AtianceExport", "ent.actiance.export.marshalToXml.appError", nil, "", 0).Wrap(err)
if err = enc.Encode(export); err != nil {
return res, fmt.Errorf("unable to convert export to XML: %w", err)
}
if err = enc.Flush(); err != nil {
return res, fmt.Errorf("unable to flush the XML encoder: %w", err)
}
enc.Flush()
// Try to disable the write timeout if the backend supports it
if _, err := filestore.TryWriteFileContext(rctx.Context(), exportBackend, xmlData, path.Join(exportDirectory, ActianceExportFilename)); err != nil {
return warningCount, model.NewAppError("ActianceExport.AtianceExport", "ent.actiance.export.write_file.appError", nil, "", 0).Wrap(err)
// Write this batch to a tmp zip, then copy the zip to the export directory.
// Using a 2M buffer because the file backend may be s3 and this optimizes speed and
// memory usage, see: https://github.com/mattermost/mattermost/pull/26629
buf := make([]byte, 1024*1024*2)
temp, err := os.CreateTemp("", "compliance-export-batch-*.zip")
if err != nil {
return res, fmt.Errorf("unable to create the batch temporary file: %w", err)
}
defer file.DeleteTemp(rctx.Logger(), temp)
zipFile := zip.NewWriter(temp)
w, err := zipFile.Create(ActianceExportFilename)
if err != nil {
return res, fmt.Errorf("unable to create the xml file in the zipFile created with the batch temporary file: %w", err)
}
if _, err = io.CopyBuffer(w, xmlData, buf); err != nil {
return res, fmt.Errorf("unable to write into the zipFile created with the batch temporary file: %w", err)
}
res.ProcessingXmlMs = time.Since(start).Milliseconds()
start = time.Now()
var missingFiles []string
for _, fileInfo := range uploadedFiles {
var attachmentSrc io.ReadCloser
attachmentSrc, nErr := fileAttachmentBackend.Reader(fileInfo.Path)
if nErr != nil {
missingFiles = append(missingFiles, "Warning:"+common_export.MissingFileMessage+" - "+fileInfo.Path)
rctx.Logger().Warn(common_export.MissingFileMessage, mlog.String("FileName", fileInfo.Path))
var attachmentReader io.ReadCloser
attachmentReader, err = fileAttachmentBackend.Reader(fileInfo.Path)
if err != nil {
missingFiles = append(missingFiles, "Warning:"+shared.MissingFileMessage+" - "+fileInfo.Path)
rctx.Logger().Warn(shared.MissingFileMessage, mlog.String("filename", fileInfo.Path))
continue
}
defer attachmentSrc.Close()
destPath := path.Join(exportDirectory, fileInfo.Path)
// There could be many uploadedFiles, so be careful about closing readers.
if err = func() error {
defer attachmentReader.Close()
var zipWriter io.Writer
zipWriter, err = zipFile.Create(fileInfo.Path)
if err != nil {
return err
}
_, nErr = exportBackend.WriteFile(attachmentSrc, destPath)
if nErr != nil {
return warningCount, model.NewAppError("ActianceExport.AtianceExport", "ent.actiance.export.write_file.appError", nil, "", 0).Wrap(nErr)
// CopyBuffer works with dirty buffers, no need to clear it.
if _, err = io.CopyBuffer(zipWriter, attachmentReader, buf); err != nil {
return err
}
return nil
}(); err != nil {
return res, fmt.Errorf("unable to write into the zipFile created with the batch temporary file: %w", err)
}
}
warningCount = int64(len(missingFiles))
if warningCount > 0 {
_, err := filestore.TryWriteFileContext(rctx.Context(), exportBackend, strings.NewReader(strings.Join(missingFiles, "\n")), path.Join(exportDirectory, ActianceWarningFilename))
res.TransferringFilesMs = time.Since(start).Milliseconds()
res.NumWarnings = len(missingFiles)
if res.NumWarnings > 0 {
var w io.Writer
w, err = zipFile.Create(ActianceWarningFilename)
if err != nil {
appErr = model.NewAppError("ActianceExport.AtianceExport", "ent.actiance.export.write_file.appError", nil, "", 0).Wrap(err)
return res, fmt.Errorf("unable to create the warning file in the zipFile created with the batch temporary file: %w", err)
}
r := strings.NewReader(strings.Join(missingFiles, "\n"))
if _, err = io.CopyBuffer(w, r, buf); err != nil {
return res, fmt.Errorf("unable to write into the zipFile created with the batch temporary file: %w", err)
}
}
return warningCount, appErr
if err = zipFile.Close(); err != nil {
return res, fmt.Errorf("unable to close the zipFile created with the batch temporary file: %w", err)
}
_, err = temp.Seek(0, io.SeekStart)
if err != nil {
return res, fmt.Errorf("unable to seek to the beginning of the the batch temporary file: %w", err)
}
start = time.Now()
// Try to write the file without a timeout due to the potential size of the file.
_, err = filestore.TryWriteFileContext(rctx.Context(), exportBackend, temp, batchPath)
if err != nil {
return res, fmt.Errorf("unable to transfer the batch zip to the file backend: %w", err)
}
res.TransferringZipMs = time.Since(start).Milliseconds()
return res, nil
}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package actiance_export
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var mainHelper *testlib.MainHelper
func TestMain(m *testing.M) {
mainHelper = testlib.NewMainHelper()
defer mainHelper.Close()
api4.SetMainHelper(mainHelper)
mainHelper.Main(m)
}

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

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package actiance_export
import (
"encoding/xml"
"io"
"testing"
"github.com/stretchr/testify/require"
)
type ChannelExportWithSpecifics struct {
XMLName xml.Name `xml:"Conversation"`
Perspective string `xml:"Perspective,attr"`
ChannelId string `xml:"-"`
RoomId string `xml:"RoomID"`
StartTime int64 `xml:"StartTimeUTC"`
JoinEvents []*JoinExport `xml:"ParticipantEntered"`
Messages []*PostExport `xml:"Message"`
FileStarts []*FileUploadStartExport `xml:"FileTransferStarted"`
FileStops []*FileUploadStopExport `xml:"FileTransferEnded"`
LeaveEvents []*LeaveExport `xml:"ParticipantLeft"`
EndTime int64 `xml:"EndTimeUTC"`
}
func GetChannelExports(t *testing.T, r io.Reader) []*ChannelExportWithSpecifics {
decoder := xml.NewDecoder(r)
var exportedChannels []*ChannelExportWithSpecifics
for {
token, err := decoder.Token()
if token == nil || err != nil {
break
}
switch se := token.(type) {
case xml.StartElement:
if se.Name.Local == "Conversation" {
var a *ChannelExportWithSpecifics
err = decoder.DecodeElement(&a, &se)
require.NoError(t, err)
exportedChannels = append(exportedChannels, a)
}
default:
}
}
return exportedChannels
}

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

@@ -5,8 +5,13 @@ package common_export
import (
"fmt"
"strconv"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/shared"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const MissingFileMessage = "File missing for post; cannot copy file to archive"
@@ -33,8 +38,6 @@ type ChannelMember struct {
Email string
Username string
}
type ChannelMembers map[string]ChannelMember
type MembersByChannel map[string]ChannelMembers
type MetadataChannel struct {
TeamId *string
@@ -52,7 +55,7 @@ type MetadataChannel struct {
}
type Metadata struct {
Channels map[string]MetadataChannel
Channels map[string]*MetadataChannel
MessagesCount int
AttachmentsCount int
StartTime int64
@@ -62,7 +65,7 @@ type Metadata struct {
func (metadata *Metadata) Update(post *model.MessageExport, attachments int) {
channelMetadata, ok := metadata.Channels[*post.ChannelId]
if !ok {
channelMetadata = MetadataChannel{
channelMetadata = &MetadataChannel{
TeamId: post.TeamId,
TeamName: post.TeamName,
TeamDisplayName: post.TeamDisplayName,
@@ -89,11 +92,93 @@ func (metadata *Metadata) Update(post *model.MessageExport, attachments int) {
metadata.Channels[*post.ChannelId] = channelMetadata
}
func GetJoinsAndLeavesForChannel(startTime int64, endTime int64, channelMembersHistory []*model.ChannelMemberHistoryResult, channelMembers ChannelMembers) ([]ChannelMemberJoin, []ChannelMemberLeave) {
joins := []ChannelMemberJoin{}
leaves := []ChannelMemberLeave{}
type ChannelExportsParams struct {
Store shared.MessageExportStore
ExportPeriodStartTime int64
ExportPeriodEndTime int64
ChannelBatchSize int
ChannelHistoryBatchSize int
ReportProgressMessage func(message string)
}
alreadyJoined := map[string]bool{}
// CalculateChannelExports returns the channel info ( map[channelId]*MetadataChannel ) and the channel user
// joins/leaves ( map[channelId][]*model.ChannelMemberHistoryResult ) for any channel that has had activity
// (posts or user join/leaves) between ExportPeriodStartTime and ExportPeriodEndTime.
func CalculateChannelExports(rctx request.CTX, opt ChannelExportsParams) (map[string]*MetadataChannel, map[string][]*model.ChannelMemberHistoryResult, error) {
// Which channels had user activity in the export period?
activeChannelIds, err := opt.Store.ChannelMemberHistory().GetChannelsWithActivityDuring(opt.ExportPeriodStartTime, opt.ExportPeriodEndTime)
if err != nil {
return nil, nil, err
}
if len(activeChannelIds) == 0 {
return nil, nil, nil
}
rctx.Logger().Debug("Started CalculateChannelExports", mlog.Int("export_period_start_time", opt.ExportPeriodStartTime), mlog.Int("export_period_end_time", opt.ExportPeriodEndTime), mlog.Int("num_active_channel_ids", len(activeChannelIds)))
message := rctx.T("ent.message_export.actiance_export.calculate_channel_exports.channel_message", model.StringMap{"NumChannels": strconv.Itoa(len(activeChannelIds))})
opt.ReportProgressMessage(message)
// For each channel, get its metadata.
channelMetadata := make(map[string]*MetadataChannel, len(activeChannelIds))
// Use batches to reduce db load and network waste.
for pos := 0; pos < len(activeChannelIds); pos += opt.ChannelBatchSize {
upTo := min(pos+opt.ChannelBatchSize, len(activeChannelIds))
batch := activeChannelIds[pos:upTo]
channels, err := opt.Store.Channel().GetMany(batch, true)
if err != nil {
return nil, nil, err
}
for _, channel := range channels {
channelMetadata[channel.Id] = &MetadataChannel{
TeamId: model.NewPointer(channel.TeamId),
ChannelId: channel.Id,
ChannelName: channel.Name,
ChannelDisplayName: channel.DisplayName,
ChannelType: channel.Type,
RoomId: fmt.Sprintf("%v - %v", ChannelTypeDisplayName(channel.Type), channel.Id),
StartTime: opt.ExportPeriodStartTime,
EndTime: opt.ExportPeriodEndTime,
}
}
}
historiesByChannelId := make(map[string][]*model.ChannelMemberHistoryResult, len(activeChannelIds))
// Now that we have metadata, get channelMemberHistories for each channel.
// Use batches to reduce total db load and network waste.
for pos := 0; pos < len(activeChannelIds); pos += opt.ChannelHistoryBatchSize {
// This may take a while, so update the system console UI.
message := rctx.T("ent.message_export.actiance_export.calculate_channel_exports.activity_message", model.StringMap{
"NumChannels": strconv.Itoa(len(activeChannelIds)),
"NumCompleted": strconv.Itoa(pos),
})
opt.ReportProgressMessage(message)
upTo := min(pos+opt.ChannelHistoryBatchSize, len(activeChannelIds))
batch := activeChannelIds[pos:upTo]
channelMemberHistories, err := opt.Store.ChannelMemberHistory().GetUsersInChannelDuring(opt.ExportPeriodStartTime, opt.ExportPeriodEndTime, batch)
if err != nil {
return nil, nil, err
}
// collect the channelMemberHistories by channelId
for _, entry := range channelMemberHistories {
historiesByChannelId[entry.ChannelId] = append(historiesByChannelId[entry.ChannelId], entry)
}
}
return channelMetadata, historiesByChannelId, nil
}
func GetJoinsAndLeavesForChannel(startTime int64, endTime int64, channelMembersHistory []*model.ChannelMemberHistoryResult,
postAuthors map[string]ChannelMember) ([]ChannelMemberJoin, []ChannelMemberLeave) {
var joins []ChannelMemberJoin
var leaves []ChannelMemberLeave
alreadyJoined := make(map[string]bool)
for _, cmh := range channelMembersHistory {
if cmh.UserDeleteAt > 0 && cmh.UserDeleteAt < startTime {
continue
@@ -129,7 +214,7 @@ func GetJoinsAndLeavesForChannel(startTime int64, endTime int64, channelMembersH
}
}
for _, member := range channelMembers {
for _, member := range postAuthors {
if alreadyJoined[member.UserId] {
continue
}

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

@@ -13,7 +13,7 @@ import (
func TestUpdateMetadata(t *testing.T) {
metadata := Metadata{
Channels: map[string]MetadataChannel{},
Channels: map[string]*MetadataChannel{},
MessagesCount: 0,
AttachmentsCount: 0,
StartTime: 0,
@@ -92,7 +92,7 @@ func TestGetJoinsAndLeavesForChannel(t *testing.T) {
name string
channel MetadataChannel
membersHistory []*model.ChannelMemberHistoryResult
usersInPosts ChannelMembers
usersInPosts map[string]ChannelMember
expectedJoins int
expectedLeaves int
}{
@@ -160,9 +160,9 @@ func TestGetJoinsAndLeavesForChannel(t *testing.T) {
name: "implicit-joins",
channel: channel,
membersHistory: nil,
usersInPosts: ChannelMembers{
"test1": ChannelMember{UserId: "test1", Email: "test1", Username: "test1"},
"test2": ChannelMember{UserId: "test2", Email: "test2", Username: "test2"},
usersInPosts: map[string]ChannelMember{
"test1": {UserId: "test1", Email: "test1", Username: "test1"},
"test2": {UserId: "test2", Email: "test2", Username: "test2"},
},
expectedJoins: 2,
expectedLeaves: 0,
@@ -174,9 +174,9 @@ func TestGetJoinsAndLeavesForChannel(t *testing.T) {
{JoinTime: 130, LeaveTime: model.NewPointer(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1"},
{JoinTime: 130, LeaveTime: model.NewPointer(int64(150)), UserId: "test3", UserEmail: "test3", Username: "test3"},
},
usersInPosts: ChannelMembers{
"test1": ChannelMember{UserId: "test1", Email: "test1", Username: "test1"},
"test2": ChannelMember{UserId: "test2", Email: "test2", Username: "test2"},
usersInPosts: map[string]ChannelMember{
"test1": {UserId: "test1", Email: "test1", Username: "test1"},
"test2": {UserId: "test2", Email: "test2", Username: "test2"},
},
expectedJoins: 3,
expectedLeaves: 2,

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

@@ -9,18 +9,18 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path"
"sort"
"slices"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/common_export"
"github.com/mattermost/mattermost/server/v8/enterprise/internal/file"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/shared"
"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/shared/filestore"
)
@@ -28,26 +28,87 @@ const (
EnterPostType = "enter"
LeavePostType = "leave"
PreviouslyJoinedPostType = "previously-joined"
CSVExportFilename = "csv_export.zip"
CSVWarningFilename = "warning.txt"
)
func CsvExport(rctx request.CTX, posts []*model.MessageExport, db store.Store, exportBackend filestore.FileBackend, fileAttachmentBackend filestore.FileBackend, exportDirectory string) (warningCount int64, appErr *model.AppError) {
dest, err := os.CreateTemp("", CSVExportFilename)
type Row struct {
CreateAt int64
UpdateAt int64
UpdateType shared.PostUpdatedType
TeamId string
TeamName string
TeamDisplayName string
ChannelId string
ChannelName string
ChannelDisplayName string
ChannelType string
UserId string
UserEmail string
Username string
PostId string
EditedByPostId string
RepliedToPostId string
PostMessage string
PostType string
UserType string
PreviewsPostId string
}
func CsvExport(rctx request.CTX, p shared.ExportParams) (shared.RunExportResults, error) {
// Build the channel exports for the channels that had post or user join/leave activity this batch.
exportData, err := shared.GetGenericExportData(p)
results := exportData.Results
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.file.creation.appError", nil, "", 0).Wrap(err)
return results, err
}
defer os.Remove(dest.Name())
zipFile := zip.NewWriter(dest)
totalRows := results.CreatedPosts + results.EditedOrigMsgPosts + results.DeletedPosts + results.EditedNewMsgPosts +
results.UpdatedPosts + results.UploadedFiles + results.DeletedFiles + results.Joins
rows := make([]Row, 0, totalRows)
for _, channel := range exportData.Exports {
rows = append(rows, getJoinLeavePosts(channel)...)
for _, p := range channel.Posts {
rows = append(rows, postToRow(p, "message", p.PostCreateAt, p.Message))
}
for _, u := range channel.UploadStarts {
rows = append(rows, attachmentToRow(shared.UploadStartToExportEntry(u)))
}
for _, d := range channel.DeletedFiles {
rows = append(rows, attachmentToRow(d))
}
}
// We need to sort all the elements by (CreateAt, PostId) because they were added by type and by channel above.
slices.SortStableFunc(rows, func(a, b Row) int {
if a.CreateAt == b.CreateAt {
return strings.Compare(a.PostId, b.PostId)
}
return int(a.CreateAt - b.CreateAt)
})
// We've got the data, now its write time:
// Write this batch to a tmp zip, then copy the zip to the export directory.
// Using a 2M buffer because the file backend may be s3 and this optimizes speed and
// memory usage, see: https://github.com/mattermost/mattermost/pull/26629
buf := make([]byte, 1024*1024*2)
temp, err := os.CreateTemp("", "compliance-export-batch-*.zip")
if err != nil {
return results, fmt.Errorf("unable to create temporary CSV export file: %w", err)
}
defer file.DeleteTemp(rctx.Logger(), temp)
zipFile := zip.NewWriter(temp)
csvFile, err := zipFile.Create("posts.csv")
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.zip.creation.appError", nil, "", 0).Wrap(err)
return results, fmt.Errorf("unable to create the zip export file: %w", err)
}
csvWriter := csv.NewWriter(csvFile)
err = csvWriter.Write([]string{
"Post Creation Time",
"Post Update Time",
"Post Update Type",
"Team Id",
"Team Name",
"Team Display Name",
@@ -68,343 +129,229 @@ func CsvExport(rctx request.CTX, posts []*model.MessageExport, db store.Store, e
})
if err != nil {
return warningCount, model.NewAppError("CsvExportPost", "ent.compliance.csv.header.export.appError", nil, "", 0).Wrap(err)
return results, fmt.Errorf("unable to add header to the CSV export: %w", err)
}
metadata := common_export.Metadata{
Channels: map[string]common_export.MetadataChannel{},
MessagesCount: 0,
AttachmentsCount: 0,
StartTime: 0,
EndTime: 0,
}
membersByChannel := make(common_export.MembersByChannel)
for _, post := range posts {
attachments, err := getPostAttachments(db, post)
if err != nil {
return warningCount, err
}
if _, ok := membersByChannel[*post.ChannelId]; !ok {
membersByChannel[*post.ChannelId] = common_export.ChannelMembers{}
}
membersByChannel[*post.ChannelId][*post.UserId] = common_export.ChannelMember{
UserId: *post.UserId,
Username: *post.Username,
IsBot: post.IsBot,
Email: *post.UserEmail,
}
metadata.Update(post, len(attachments))
}
joinLeavePosts, appErr2 := getJoinLeavePosts(metadata.Channels, membersByChannel, db)
if appErr2 != nil {
return warningCount, appErr2
}
postsGenerator := mergePosts(joinLeavePosts, posts)
for post := postsGenerator(); post != nil; post = postsGenerator() {
if err = csvWriter.Write(postToRow(post, post.PostCreateAt, *post.PostMessage)); err != nil {
return warningCount, model.NewAppError("CsvExportPost", "ent.compliance.csv.post.export.appError", nil, "", 0).Wrap(err)
}
if post.PostDeleteAt != nil && *post.PostDeleteAt > 0 && post.PostProps != nil {
props := map[string]any{}
if json.Unmarshal([]byte(*post.PostProps), &props) == nil {
if _, ok := props[model.PostPropsDeleteBy]; ok {
if err = csvWriter.Write(postToRow(post, post.PostDeleteAt, "delete "+*post.PostMessage)); err != nil {
return warningCount, model.NewAppError("CsvExportPost", "ent.compliance.csv.post.export.appError", nil, "", 0).Wrap(err)
}
}
}
}
var attachments []*model.FileInfo
attachments, appErr = getPostAttachments(db, post)
if appErr != nil {
return warningCount, appErr
}
for _, attachment := range attachments {
if err = csvWriter.Write(attachmentToRow(post, attachment)); err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.attachment.export.appError", nil, "", 0).Wrap(err)
}
for _, row := range rows {
if err = csvWriter.Write(rowToStringSlice(row)); err != nil {
return results, fmt.Errorf("unable to export a row: %w", err)
}
}
csvWriter.Flush()
var missingFiles []string
for _, post := range posts {
attachments, err := getPostAttachments(db, post)
for _, post := range p.Posts {
var attachments []*model.FileInfo
attachments, err = shared.GetPostAttachments(p.Db, post)
if err != nil {
return warningCount, err
return results, err
}
for _, attachment := range attachments {
var attachmentSrc io.ReadCloser
attachmentSrc, nErr := fileAttachmentBackend.Reader(attachment.Path)
var r io.ReadCloser
r, nErr := p.FileAttachmentBackend.Reader(attachment.Path)
if nErr != nil {
missingFiles = append(missingFiles, "Warning:"+common_export.MissingFileMessage+" - Post: "+*post.PostId+" - "+attachment.Path)
rctx.Logger().Warn(common_export.MissingFileMessage, mlog.String("PostId", *post.PostId), mlog.String("FileName", attachment.Path))
missingFiles = append(missingFiles, "Warning:"+shared.MissingFileMessage+" - Post: "+*post.PostId+" - "+attachment.Path)
rctx.Logger().Warn(shared.MissingFileMessage, mlog.String("post_id", *post.PostId), mlog.String("filename", attachment.Path))
continue
}
defer attachmentSrc.Close()
attachmentDst, err := zipFile.Create(path.Join("files", *post.PostId, fmt.Sprintf("%s-%s", attachment.Id, path.Base(attachment.Path))))
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.attachment.copy.appError", nil, "", 0).Wrap(err)
}
// Probably don't need to be this careful (see actiance_export.go), but may as well be consistent.
if err = func() error {
defer r.Close()
var attachmentDst io.Writer
attachmentDst, err = zipFile.Create(path.Join("files", *post.PostId, fmt.Sprintf("%s-%s", attachment.Id, path.Base(attachment.Path))))
if err != nil {
return err
}
_, err = io.Copy(attachmentDst, attachmentSrc)
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.attachment.copy.appError", nil, "", 0).Wrap(err)
_, err = io.CopyBuffer(attachmentDst, r, buf)
if err != nil {
return err
}
return nil
}(); err != nil {
return results, fmt.Errorf("unable to copy the attachment into the zip file: %w", err)
}
}
}
warningCount = int64(len(missingFiles))
if warningCount > 0 {
results.NumWarnings = len(missingFiles)
if results.NumWarnings > 0 {
metadataFile, _ := zipFile.Create(CSVWarningFilename)
for _, value := range missingFiles {
_, err = metadataFile.Write([]byte(value + "\n"))
if err != nil {
appErr = model.NewAppError("CsvExport", "ent.compliance.csv.warning.appError", nil, "", 0).Wrap(err)
return warningCount, appErr
return results, fmt.Errorf("unable to create the warning file: %w", err)
}
}
}
metadataFile, err := zipFile.Create("metadata.json")
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.metadata.json.zipfile.appError", nil, "", 0).Wrap(err)
return results, fmt.Errorf("unable to create the zip file: %w", err)
}
data, err := json.MarshalIndent(metadata, "", " ")
data, err := json.MarshalIndent(exportData.Metadata, "", " ")
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.metadata.json.marshalling.appError", nil, "", 0).Wrap(err)
return results, fmt.Errorf("unable to convert metadata to json: %w", err)
}
_, err = metadataFile.Write(data)
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.metadata.export.appError", nil, "", 0).Wrap(err)
return results, fmt.Errorf("unable to add metadata file to the zip file: %w", err)
}
err = zipFile.Close()
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.metadata.close.appError", nil, "", 0).Wrap(err)
return results, fmt.Errorf("unable to close the zip file: %w", err)
}
_, err = dest.Seek(0, 0)
_, err = temp.Seek(0, 0)
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.seek.appError", nil, "", 0).Wrap(err)
return results, fmt.Errorf("unable to seek to start of export file: %w", err)
}
// Try to write the file without a timeout due to the potential size of the file.
_, err = filestore.TryWriteFileContext(rctx.Context(), exportBackend, dest, path.Join(exportDirectory, CSVExportFilename))
_, err = filestore.TryWriteFileContext(rctx.Context(), p.ExportBackend, temp, p.BatchPath)
if err != nil {
return warningCount, model.NewAppError("CsvExport", "ent.compliance.csv.write_file.appError", nil, "", http.StatusInternalServerError).Wrap(err)
return results, fmt.Errorf("unable to write the csv file: %w", err)
}
return warningCount, appErr
return results, nil
}
func mergePosts(left []*model.MessageExport, right []*model.MessageExport) func() *model.MessageExport {
leftCursor := 0
rightCursor := 0
return func() *model.MessageExport {
if leftCursor >= len(left) && rightCursor >= len(right) {
return nil
func getJoinLeavePosts(channel shared.ChannelExport) []Row {
var joinLeavePosts []Row
for _, join := range channel.JoinEvents {
enterMessage := fmt.Sprintf("User %s (%s) joined the channel", join.Username, join.UserEmail)
enterPostType := EnterPostType
if join.JoinTime <= channel.StartTime {
enterPostType = PreviouslyJoinedPostType
enterMessage = fmt.Sprintf("User %s (%s) was already in the channel", join.Username, join.UserEmail)
}
if leftCursor >= len(left) {
rightCursor += 1
return right[rightCursor-1]
}
if rightCursor >= len(right) {
leftCursor += 1
return left[leftCursor-1]
}
if *left[leftCursor].PostCreateAt <= *right[rightCursor].PostCreateAt {
leftCursor += 1
return left[leftCursor-1]
}
rightCursor += 1
return right[rightCursor-1]
}
}
func getJoinLeavePosts(channels map[string]common_export.MetadataChannel, membersByChannel common_export.MembersByChannel, db store.Store) ([]*model.MessageExport, *model.AppError) {
joinLeavePosts := []*model.MessageExport{}
for _, channel := range channels {
channelMembersHistory, err := db.ChannelMemberHistory().GetUsersInChannelDuring(channel.StartTime, channel.EndTime, channel.ChannelId)
if err != nil {
return nil, model.NewAppError("getJoinLeavePosts", "ent.get_users_in_channel_during", nil, "", http.StatusInternalServerError).Wrap(err)
}
joins, leaves := common_export.GetJoinsAndLeavesForChannel(channel.StartTime, channel.EndTime, channelMembersHistory, membersByChannel[channel.ChannelId])
for _, join := range joins {
enterMessage := fmt.Sprintf("User %s (%s) joined the channel", join.Username, join.Email)
enterPostType := EnterPostType
createAt := model.NewPointer(join.Datetime)
channelCopy := channel
if join.Datetime <= channel.StartTime {
enterPostType = PreviouslyJoinedPostType
enterMessage = fmt.Sprintf("User %s (%s) was already in the channel", join.Username, join.Email)
createAt = model.NewPointer(channel.StartTime)
}
joinLeavePosts = append(
joinLeavePosts,
&model.MessageExport{
TeamId: channel.TeamId,
TeamName: channel.TeamName,
TeamDisplayName: channel.TeamDisplayName,
ChannelId: &channelCopy.ChannelId,
ChannelName: &channelCopy.ChannelName,
ChannelDisplayName: &channelCopy.ChannelDisplayName,
ChannelType: &channelCopy.ChannelType,
UserId: model.NewPointer(join.UserId),
UserEmail: model.NewPointer(join.Email),
Username: model.NewPointer(join.Username),
IsBot: join.IsBot,
PostId: model.NewPointer(""),
PostCreateAt: createAt,
PostMessage: &enterMessage,
PostType: &enterPostType,
PostOriginalId: model.NewPointer(""),
PostFileIds: []string{},
joinLeavePosts = append(
joinLeavePosts,
postToRow(shared.PostExport{
MessageExport: model.MessageExport{
TeamId: &channel.TeamId,
TeamName: &channel.TeamName,
TeamDisplayName: &channel.TeamDisplayName,
ChannelId: &channel.ChannelId,
ChannelName: &channel.ChannelName,
ChannelDisplayName: &channel.DisplayName,
ChannelType: &channel.ChannelType,
UserId: &join.UserId,
UserEmail: &join.UserEmail,
Username: &join.Username,
IsBot: join.UserType == shared.Bot,
PostId: model.NewPointer(""),
PostCreateAt: &join.JoinTime,
PostMessage: &enterMessage,
PostType: &enterPostType,
PostOriginalId: model.NewPointer(""),
PostFileIds: []string{},
},
)
}, enterPostType, &join.JoinTime, enterMessage),
)
}
for _, leave := range channel.LeaveEvents {
if leave.ClosedOut {
// csv does not record closed-out leaves; see export_data.go for further explanation.
continue
}
for _, leave := range leaves {
leaveMessage := fmt.Sprintf("User %s (%s) leaved the channel", leave.Username, leave.Email)
leavePostType := LeavePostType
channelCopy := channel
leaveMessage := fmt.Sprintf("User %s (%s) left the channel", leave.Username, leave.UserEmail)
leavePostType := LeavePostType
joinLeavePosts = append(
joinLeavePosts,
&model.MessageExport{
TeamId: channel.TeamId,
TeamName: channel.TeamName,
TeamDisplayName: channel.TeamDisplayName,
ChannelId: &channelCopy.ChannelId,
ChannelName: &channelCopy.ChannelName,
ChannelDisplayName: &channelCopy.ChannelDisplayName,
ChannelType: &channelCopy.ChannelType,
UserId: model.NewPointer(leave.UserId),
UserEmail: model.NewPointer(leave.Email),
Username: model.NewPointer(leave.Username),
IsBot: leave.IsBot,
PostId: model.NewPointer(""),
PostCreateAt: model.NewPointer(leave.Datetime),
PostMessage: &leaveMessage,
PostType: &leavePostType,
PostOriginalId: model.NewPointer(""),
PostFileIds: []string{},
joinLeavePosts = append(
joinLeavePosts,
postToRow(shared.PostExport{
MessageExport: model.MessageExport{
TeamId: &channel.TeamId,
TeamName: &channel.TeamName,
TeamDisplayName: &channel.TeamDisplayName,
ChannelId: &channel.ChannelId,
ChannelName: &channel.ChannelName,
ChannelDisplayName: &channel.DisplayName,
ChannelType: &channel.ChannelType,
UserId: &leave.UserId,
UserEmail: &leave.UserEmail,
Username: &leave.Username,
IsBot: leave.UserType == shared.Bot,
PostId: model.NewPointer(""),
PostCreateAt: &leave.LeaveTime,
PostMessage: &leaveMessage,
PostType: &leavePostType,
PostOriginalId: model.NewPointer(""),
PostFileIds: []string{},
},
)
}
}, leavePostType, &leave.LeaveTime, leaveMessage),
)
}
sort.Slice(joinLeavePosts, func(i, j int) bool {
return *joinLeavePosts[i].PostCreateAt < *joinLeavePosts[j].PostCreateAt
})
return joinLeavePosts, nil
return joinLeavePosts
}
func getPostAttachments(db store.Store, post *model.MessageExport) ([]*model.FileInfo, *model.AppError) {
// if the post included any files, we need to add special elements to the export.
if len(post.PostFileIds) == 0 {
return []*model.FileInfo{}, nil
}
attachments, err := db.FileInfo().GetForPost(*post.PostId, true, true, false)
if err != nil {
return nil, model.NewAppError("getPostAttachments", "ent.message_export.csv_export.get_attachment_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return attachments, nil
}
func postToRow(post *model.MessageExport, createTime *int64, message string) []string {
teamId := ""
teamName := ""
teamDisplayName := ""
if post.TeamId != nil {
teamId = *post.TeamId
}
if post.TeamName != nil {
teamName = *post.TeamName
}
if post.TeamDisplayName != nil {
teamDisplayName = *post.TeamDisplayName
}
postType := "message"
if post.PostType != nil && *post.PostType != "" {
postType = *post.PostType
}
postRootId := ""
if post.PostRootId != nil {
postRootId = *post.PostRootId
}
func postToRow(p shared.PostExport, postType string, createTime *int64, message string) Row {
userType := "user"
if post.IsBot {
if p.IsBot {
userType = "bot"
}
return Row{
CreateAt: model.SafeDereference(createTime),
UpdateAt: model.SafeDereference(p.PostUpdateAt),
UpdateType: p.UpdatedType,
TeamId: model.SafeDereference(p.TeamId),
TeamName: model.SafeDereference(p.TeamName),
TeamDisplayName: model.SafeDereference(p.TeamDisplayName),
ChannelId: model.SafeDereference(p.ChannelId),
ChannelName: model.SafeDereference(p.ChannelName),
ChannelDisplayName: model.SafeDereference(p.ChannelDisplayName),
ChannelType: shared.ChannelTypeDisplayName(model.SafeDereference(p.ChannelType)),
UserId: model.SafeDereference(p.UserId),
UserEmail: model.SafeDereference(p.UserEmail),
Username: model.SafeDereference(p.Username),
PostId: model.SafeDereference(p.PostId),
EditedByPostId: p.EditedNewMsgId,
RepliedToPostId: model.SafeDereference(p.PostRootId),
PostMessage: message,
PostType: postType,
UserType: userType,
PreviewsPostId: p.PreviewID(),
}
}
func rowToStringSlice(r Row) []string {
return []string{
strconv.FormatInt(*createTime, 10),
teamId,
teamName,
teamDisplayName,
*post.ChannelId,
*post.ChannelName,
*post.ChannelDisplayName,
common_export.ChannelTypeDisplayName(*post.ChannelType),
*post.UserId,
*post.UserEmail,
*post.Username,
*post.PostId,
*post.PostOriginalId,
postRootId,
message,
postType,
userType,
post.PreviewID(),
strconv.FormatInt(r.CreateAt, 10),
strconv.FormatInt(r.UpdateAt, 10),
string(r.UpdateType),
r.TeamId,
r.TeamName,
r.TeamDisplayName,
r.ChannelId,
r.ChannelName,
r.ChannelDisplayName,
r.ChannelType,
r.UserId,
r.UserEmail,
r.Username,
r.PostId,
r.EditedByPostId,
r.RepliedToPostId,
r.PostMessage,
r.PostType,
r.UserType,
r.PreviewsPostId,
}
}
func attachmentToRow(post *model.MessageExport, attachment *model.FileInfo) []string {
row := postToRow(post, post.PostCreateAt, *post.PostMessage)
attachmentEntry := fmt.Sprintf("%s (files/%s/%s-%s)", attachment.Name, *post.PostId, attachment.Id, path.Base(attachment.Path))
attachmentMessage := "attachment"
userType := row[len(row)-2]
if attachment.DeleteAt > 0 && post.PostDeleteAt != nil {
deleteRow := postToRow(post, post.PostDeleteAt, *post.PostMessage)
row = append(
deleteRow[:len(deleteRow)-4],
attachmentEntry,
"deleted "+attachmentMessage,
userType,
)
} else {
row = append(
row[:len(row)-4],
attachmentEntry,
attachmentMessage,
userType,
)
func attachmentToRow(post shared.PostExport) Row {
message := strings.TrimSpace(fmt.Sprintf("%s (files/%s/%s-%s)", post.FileInfo.Name, *post.PostId, post.FileInfo.Id, path.Base(post.FileInfo.Path)))
postType := "attachment"
if post.UpdatedType == shared.FileDeleted {
postType = "deleted attachment"
post.PostUpdateAt = model.NewPointer(post.FileInfo.DeleteAt)
}
return row
return postToRow(post, postType, post.PostCreateAt, message)
}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@@ -6,8 +6,8 @@ package global_relay_export
import (
"archive/zip"
"context"
"fmt"
"io"
"net/http"
"net/mail"
"net/smtp"
"os"
@@ -16,14 +16,14 @@ import (
"github.com/mattermost/mattermost/server/public/model"
)
func Deliver(export *os.File, config *model.Config) *model.AppError {
func Deliver(export *os.File, config *model.Config) error {
info, err := export.Stat()
if err != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.unable_to_get_file_info.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to get the information of the export temporary file: %w", err)
}
zipFile, err := zip.NewReader(export, info.Size())
if err != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.unable_to_open_zip_file_data.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to open the export temporary file: %w", err)
}
to := *config.MessageExportSettings.GlobalRelaySettings.EmailAddress
@@ -33,7 +33,7 @@ func Deliver(export *os.File, config *model.Config) *model.AppError {
conn, err := connectToSMTPServer(ctx, config)
if err != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.unable_to_connect_smtp_server.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to connect to the smtp server: %w", err)
}
defer conn.Close()
@@ -55,56 +55,56 @@ func Deliver(export *os.File, config *model.Config) *model.AppError {
var nErr error
conn, nErr = connectToSMTPServer(context.Background(), config)
if nErr != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.unable_to_connect_smtp_server.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
return fmt.Errorf("unable to connect to the smtp server: %w", nErr)
}
}
}
return nil
}
func deliverEmail(c *smtp.Client, mailFile *zip.File, from string, to string) *model.AppError {
func deliverEmail(c *smtp.Client, mailFile *zip.File, from string, to string) error {
mailData, err := mailFile.Open()
if err != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.unable_to_open_email_file.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to get the an email from the temporary file: %w", err)
}
defer mailData.Close()
err = c.Mail(from)
if err != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.from_address.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to set the email From address: %w", err)
}
err = c.Rcpt(to)
if err != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.to_address.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to set the email To address: %w", err)
}
w, err := c.Data()
if err != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.msg_data.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to write the email message: %w", err)
}
_, err = io.Copy(w, mailData)
if err != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.msg.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to set the email message: %w", err)
}
err = w.Close()
if err != nil {
return model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.close.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to deliver the email to Global Relay: %w", err)
}
return nil
}
func getFrom(mailFile *zip.File) (string, *model.AppError) {
func getFrom(mailFile *zip.File) (string, error) {
mailData, err := mailFile.Open()
if err != nil {
return "", model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.unable_to_open_email_file.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return "", fmt.Errorf("unable to get the an email from the temporary file: %w", err)
}
defer mailData.Close()
message, err := mail.ReadMessage(mailData)
if err != nil {
return "", model.NewAppError("GlobalRelayDelivery", "ent.message_export.global_relay_export.deliver.parse_mail.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return "", fmt.Errorf("unable to read the email information: %w", err)
}
return message.Header.Get("From"), nil
}

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

@@ -30,8 +30,8 @@ func TestDeliver(t *testing.T) {
defer emptyFile.Close()
defer os.Remove(emptyFile.Name())
appErr := Deliver(emptyFile, config)
assert.NotNil(t, appErr)
err = Deliver(emptyFile, config)
assert.Error(t, err)
})
t.Run("Testing empty zip file", func(t *testing.T) {
@@ -46,8 +46,8 @@ func TestDeliver(t *testing.T) {
err = mail.DeleteMailBox(*config.MessageExportSettings.GlobalRelaySettings.EmailAddress)
require.NoError(t, err)
appErr := Deliver(emptyZipFile, config)
assert.Nil(t, appErr)
err = Deliver(emptyZipFile, config)
assert.NoError(t, err)
_, err = mail.GetMailBox(*config.MessageExportSettings.GlobalRelaySettings.EmailAddress)
require.Error(t, err)
@@ -87,8 +87,8 @@ func TestDeliver(t *testing.T) {
err = mail.DeleteMailBox(*config.MessageExportSettings.GlobalRelaySettings.EmailAddress)
require.NoError(t, err)
appErr := Deliver(emptyZipFile, config)
assert.Nil(t, appErr)
err = Deliver(emptyZipFile, config)
assert.NoError(t, err)
mailbox, err := mail.GetMailBox(*config.MessageExportSettings.GlobalRelaySettings.EmailAddress)
require.NoError(t, err)
@@ -130,8 +130,8 @@ func TestDeliver(t *testing.T) {
err = mail.DeleteMailBox(*config.MessageExportSettings.GlobalRelaySettings.EmailAddress)
require.NoError(t, err)
appErr := Deliver(emptyZipFile, config)
assert.Nil(t, appErr)
err = Deliver(emptyZipFile, config)
assert.NoError(t, err)
mailbox, err := mail.GetMailBox(*config.MessageExportSettings.GlobalRelaySettings.EmailAddress)
require.NoError(t, err)

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

@@ -9,7 +9,7 @@ import (
"fmt"
"io"
"mime"
"net/http"
"os"
"sort"
"strings"
"time"
@@ -17,12 +17,11 @@ import (
"github.com/jaytaylor/html2text"
gomail "gopkg.in/mail.v2"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/common_export"
"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/enterprise/internal/file"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/shared"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
)
@@ -32,221 +31,228 @@ const (
GlobalRelayChannelNameHeader = "X-Mattermost-ChannelName"
GlobalRelayChannelIDHeader = "X-Mattermost-ChannelID"
GlobalRelayChannelTypeHeader = "X-Mattermost-ChannelType"
MaxEmailBytes = 250 << (10 * 2)
MaxEmailsPerConnection = 400
)
type AllExport map[string][]*ChannelExport
// MaxEmailBytes is a var because it needs to be set in tests. Otherwise it shouldn't be touched.
var MaxEmailBytes int64 = 250 * 1024 * 1024 // 250MB
type ChannelExport struct {
ChannelId string // the unique id of the channel
ChannelName string // the name of the channel
ChannelType model.ChannelType // the channel type
StartTime int64 // utc timestamp (seconds), start of export period or create time of channel, whichever is greater. Example: 1366611728.
EndTime int64 // utc timestamp (seconds), end of export period or delete time of channel, whichever is lesser. Example: 1366611728.
Participants []ParticipantRow // summary information about the conversation participants
Messages []Message // the messages that were sent during the conversation
ExportedOn int64 // utc timestamp (seconds), when this export was generated
numUserMessages map[string]int // key is user id, value is number of messages that they sent during this period
uploadedFiles []*model.FileInfo // any files that were uploaded to the channel during the export period
bytes int64
TeamId string
TeamName string
TeamDisplayName string
ChannelId string // the unique id of the channel
ChannelName string // the name of the channel
ChannelDisplayName string
ChannelType model.ChannelType // the channel type
StartTime int64 // utc timestamp (seconds), start of export period or create time of channel, whichever is greater. Example: 1366611728.
EndTime int64 // utc timestamp (seconds), end of export period or delete time of channel, whichever is lesser. Example: 1366611728.
Participants []ParticipantRow // summary information about the conversation participants
Messages []Message // the messages that were sent during the conversation
ExportedOn int64 // utc timestamp (seconds), when this export was generated
numUserMessages map[string]int // key is user id, value is number of messages that they sent during this period
uploadedFiles []*model.FileInfo // any files that were uploaded to the channel during the export period
bytes int64
}
// a row in the summary table at the top of the export
type ParticipantRow struct {
Username string
UserType string
Email string
JoinTime int64
LeaveTime int64
shared.JoinExport
MessagesSent int
}
type Message struct {
Id string
SentTime int64
SenderId string
SenderUsername string
PostType string
PostUsername string
SenderUserType string
SenderEmail string
Message string
PreviewsPost string
UpdateAt int64
UpdateType shared.PostUpdatedType
EditedNewMsgId string
}
func GlobalRelayExport(rctx request.CTX, posts []*model.MessageExport, db store.Store, fileAttachmentBackend filestore.FileBackend, dest io.Writer, templates *templates.Container) ([]string, int64, *model.AppError) {
var warningCount int64
attachmentsRemovedPostIDs := []string{}
allExports := make(map[string][]*ChannelExport)
zipFile := zip.NewWriter(dest)
membersByChannel := common_export.MembersByChannel{}
metadata := common_export.Metadata{
Channels: map[string]common_export.MetadataChannel{},
MessagesCount: 0,
AttachmentsCount: 0,
StartTime: 0,
EndTime: 0,
func GlobalRelayExport(rctx request.CTX, p shared.ExportParams) (shared.RunExportResults, error) {
exportData, err := shared.GetGenericExportData(p)
results := exportData.Results
if err != nil {
return results, err
}
for _, post := range posts {
if _, ok := membersByChannel[*post.ChannelId]; !ok {
membersByChannel[*post.ChannelId] = common_export.ChannelMembers{}
}
var attachmentsRemovedPostIDs []string
allExports := make(map[string][]*ChannelExport, len(exportData.Exports))
membersByChannel[*post.ChannelId][*post.UserId] = common_export.ChannelMember{
UserId: *post.UserId,
Username: *post.Username,
IsBot: post.IsBot,
Email: *post.UserEmail,
}
// save a pointer to the joins for each channel, to be used later in getParticipants
joinsByChannel := make(map[string][]shared.JoinExport, len(exportData.Exports))
attachments := []*model.FileInfo{}
if len(post.PostFileIds) > 0 {
var err error
attachments, err = db.FileInfo().GetForPost(*post.PostId, true, true, false)
if err != nil {
return nil, warningCount, model.NewAppError("GlobalRelayExport", "ent.message_export.global_relay_export.get_attachment_error", nil, "", http.StatusInternalServerError).Wrap(err)
for _, channel := range exportData.Exports {
for _, post := range channel.Posts {
attachmentsRemoved := addToExports(rctx, allExports, channel, post)
attachmentsRemovedPostIDs = append(attachmentsRemovedPostIDs, attachmentsRemoved...)
}
if len(channel.Posts) == 0 {
// channel has no posts, but it was exported anyway, so it must have joins and leaves.
if _, present := allExports[channel.ChannelId]; !present {
// we found a new channel
allExports[channel.ChannelId] = []*ChannelExport{genericChannelToChannelExport(channel)}
}
}
attachmentsRemoved := addToExports(rctx, attachments, allExports, post)
attachmentsRemovedPostIDs = append(attachmentsRemovedPostIDs, attachmentsRemoved...)
metadata.Update(post, len(attachments))
joinsByChannel[channel.ChannelId] = channel.JoinEvents
}
tmpFile, err := os.CreateTemp("", "")
if err != nil {
return results, fmt.Errorf("unable to open the temporary export file: %w", err)
}
defer file.DeleteTemp(rctx.Logger(), tmpFile)
zipFile := zip.NewWriter(tmpFile)
// export each channelExport (sometimes multiple channelExport per real channel)
for _, channelExportList := range allExports {
for batchId, channelExport := range channelExportList {
participants, appErr := getParticipants(db, channelExport, membersByChannel[channelExport.ChannelId])
if appErr != nil {
return nil, warningCount, appErr
}
channelExport.Participants = participants
channelExport.ExportedOn = time.Now().Unix() * 1000
// we need to make the participant list for each channelExport "batch" (multiple "batches"per real channel)
// because each batch will have its own number of messages for that participant.
channelExport.Participants = getParticipants(channelExport, joinsByChannel[channelExport.ChannelId])
channelExport.ExportedOn = p.JobStartTime
channelExportFile, err := zipFile.Create(fmt.Sprintf("%s - (%s) - %d.eml", channelExport.ChannelName, channelExport.ChannelId, batchId))
var channelExportFile io.Writer
channelExportFile, err = zipFile.Create(fmt.Sprintf("%s - (%s) - %d.eml", channelExport.ChannelName, channelExport.ChannelId, batchId))
if err != nil {
return nil, warningCount, model.NewAppError("GlobalRelayExport", "ent.message_export.global_relay.create_file_in_zip.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return results, fmt.Errorf("unable to create the eml file: %w", err)
}
if appErr, warningCount = generateEmail(rctx, fileAttachmentBackend, channelExport, templates, channelExportFile); appErr != nil {
return nil, warningCount, appErr
if results.NumWarnings, err = generateEmail(rctx, p.FileAttachmentBackend, channelExport, p.Templates, channelExportFile); err != nil {
return results, err
}
}
}
err := zipFile.Close()
err = zipFile.Close()
if err != nil {
return nil, warningCount, model.NewAppError("GlobalRelayExport", "ent.message_export.global_relay.close_zip_file.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return results, fmt.Errorf("unable to close the zip file using tmpFile.Name: %v, err: %w", tmpFile.Name(), err)
}
return attachmentsRemovedPostIDs, warningCount, nil
_, err = tmpFile.Seek(0, 0)
if err != nil {
return results, fmt.Errorf("unable to re-read the Global Relay temporary export file using tmpFile.Name: %v, err: %w", tmpFile.Name(), err)
}
if p.ExportType == model.ComplianceExportTypeGlobalrelayZip {
// Try to disable the write timeout for the potentially big export file.
_, err = filestore.TryWriteFileContext(rctx.Context(), p.ExportBackend, tmpFile, p.BatchPath)
if err != nil {
return results, fmt.Errorf("unable to write the global relay file, using tmpFile.Name: %v, batchPath: %v, err: %w", tmpFile.Name(), p.BatchPath, err)
}
} else {
err = Deliver(tmpFile, p.Config)
if err != nil {
return results, fmt.Errorf("unable to deliver tmpFile.Name: %v, err: %w", tmpFile.Name(), err)
}
}
if len(attachmentsRemovedPostIDs) > 0 {
rctx.Logger().Warn("Global Relay Attachments Removed because they were too large to send to Global Relay",
mlog.Int("number_of_attachments_removed", len(attachmentsRemovedPostIDs)))
rctx.Logger().Warn("List of posts which had attachments removed",
mlog.Array("post_ids", attachmentsRemovedPostIDs))
}
return results, nil
}
func addToExports(rctx request.CTX, attachments []*model.FileInfo, exports map[string][]*ChannelExport, post *model.MessageExport) []string {
// addToExports adds the post to the allExports collection. allExports keeps a map of channelId->[]*ChannelExport.
// If a channelId has an existing []*ChannelExport, it adds post to the last ChannelExport in that list.
// If the last ChannelExport is too big, it starts a new ChannelExport and appends it to the list (a new "batch").
func addToExports(rctx request.CTX, allExports map[string][]*ChannelExport, genericChannel shared.ChannelExport,
post shared.PostExport) []string {
var channelExport *ChannelExport
attachmentsRemovedPostIDs := []string{}
if channelExports, present := exports[*post.ChannelId]; !present {
var attachmentsRemovedPostIDs []string
if channelExports, present := allExports[*post.ChannelId]; !present {
// we found a new channel
channelExport = &ChannelExport{
ChannelId: *post.ChannelId,
ChannelName: *post.ChannelDisplayName,
ChannelType: *post.ChannelType,
StartTime: *post.PostCreateAt,
Messages: make([]Message, 0),
Participants: make([]ParticipantRow, 0),
numUserMessages: make(map[string]int),
uploadedFiles: make([]*model.FileInfo, 0),
bytes: 0,
}
exports[*post.ChannelId] = []*ChannelExport{channelExport}
channelExport = genericChannelToChannelExport(genericChannel)
allExports[*post.ChannelId] = []*ChannelExport{channelExport}
} else {
// we already know about this channel
channelExport = channelExports[len(channelExports)-1]
}
// Create a new ChannelExport if it would be too many bytes to add the post
fileBytes := fileInfoListBytes(attachments)
msgBytes := int64(len(*post.PostMessage))
postBytes := fileBytes + msgBytes
postTooLargeForChannelBatch := channelExport.bytes+postBytes > MaxEmailBytes
postAloneTooLargeToSend := postBytes > MaxEmailBytes // Attachments must be removed from export, they're too big to send.
if postAloneTooLargeToSend {
// Create a new ChannelExport if it would be too many bytes to add the post.
// NOTE: we are only exporting attachment starts.
attachmentStarts := make([]*model.FileInfo, 0, len(post.AttachmentCreates))
for _, start := range post.AttachmentCreates {
attachmentStarts = append(attachmentStarts, start.FileInfo)
}
fileBytes := fileInfoListBytes(attachmentStarts)
postBytes := fileBytes + msgBytes
// NOTE: This is only a rough estimate -- we're not including the txt or html portion of the email...
attachmentsAloneTooLargeToSend := fileBytes > MaxEmailBytes // Attachments must be removed from export, they're too big to send.
if attachmentsAloneTooLargeToSend {
postBytes -= fileBytes
}
postTooLargeForChannelBatch := channelExport.bytes+postBytes > MaxEmailBytes
if attachmentsAloneTooLargeToSend {
attachmentsRemovedPostIDs = append(attachmentsRemovedPostIDs, *post.PostId)
}
if postTooLargeForChannelBatch && !postAloneTooLargeToSend {
channelExport = &ChannelExport{
ChannelId: *post.ChannelId,
ChannelName: *post.ChannelDisplayName,
ChannelType: *post.ChannelType,
StartTime: *post.PostCreateAt,
Messages: make([]Message, 0),
Participants: make([]ParticipantRow, 0),
numUserMessages: make(map[string]int),
uploadedFiles: make([]*model.FileInfo, 0),
bytes: 0,
}
exports[*post.ChannelId] = append(exports[*post.ChannelId], channelExport)
// new "batch"
if postTooLargeForChannelBatch {
channelExport = genericChannelToChannelExport(genericChannel)
allExports[*post.ChannelId] = append(allExports[*post.ChannelId], channelExport)
}
addPostToChannelExport(rctx, channelExport, post)
// if this post includes files, add them to the collection
for _, fileInfo := range attachments {
addAttachmentToChannelExport(channelExport, post, fileInfo, postAloneTooLargeToSend)
}
addAttachmentsToChannelExport(channelExport, post, post.AttachmentCreates, post.AttachmentDeletes, attachmentsAloneTooLargeToSend)
channelExport.bytes += postBytes
return attachmentsRemovedPostIDs
}
func getParticipants(db store.Store, channelExport *ChannelExport, members common_export.ChannelMembers) ([]ParticipantRow, *model.AppError) {
participantsMap := map[string]ParticipantRow{}
channelMembersHistory, err := db.ChannelMemberHistory().GetUsersInChannelDuring(channelExport.StartTime, channelExport.EndTime, channelExport.ChannelId)
if err != nil {
return nil, model.NewAppError("getParticipants", "ent.get_users_in_channel_during", nil, "", http.StatusInternalServerError).Wrap(err)
func genericChannelToChannelExport(genericChannel shared.ChannelExport) *ChannelExport {
return &ChannelExport{
TeamId: genericChannel.TeamId,
TeamName: genericChannel.TeamName,
TeamDisplayName: genericChannel.TeamDisplayName,
ChannelId: genericChannel.ChannelId,
ChannelName: genericChannel.ChannelName,
ChannelDisplayName: genericChannel.DisplayName,
ChannelType: genericChannel.ChannelType,
StartTime: genericChannel.StartTime,
EndTime: genericChannel.EndTime,
// we can't preallocate sizes here because we don't know how many will be in this "batch"
Participants: make([]ParticipantRow, 0),
Messages: make([]Message, 0),
ExportedOn: 0,
numUserMessages: make(map[string]int),
uploadedFiles: make([]*model.FileInfo, 0),
bytes: 0,
}
}
joins, leaves := common_export.GetJoinsAndLeavesForChannel(channelExport.StartTime, channelExport.EndTime, channelMembersHistory, members)
for _, join := range joins {
userType := "user"
if join.IsBot {
userType = "bot"
}
if _, ok := participantsMap[join.UserId]; !ok {
participantsMap[join.UserId] = ParticipantRow{
Username: join.Username,
UserType: userType,
Email: join.Email,
JoinTime: join.Datetime,
LeaveTime: channelExport.EndTime,
MessagesSent: channelExport.numUserMessages[join.UserId],
}
}
}
for _, leave := range leaves {
if participantRow, ok := participantsMap[leave.UserId]; ok {
participantRow.LeaveTime = leave.Datetime //nolint:govet
}
}
participants := []ParticipantRow{}
for _, participant := range participantsMap {
participants = append(participants, participant)
func getParticipants(channelExport *ChannelExport, joinEvents []shared.JoinExport) []ParticipantRow {
participants := make([]ParticipantRow, 0, len(joinEvents))
for _, j := range joinEvents {
participants = append(participants, ParticipantRow{
JoinExport: j,
MessagesSent: channelExport.numUserMessages[j.UserId],
})
}
sort.Slice(participants, func(i, j int) bool {
return participants[i].Username < participants[j].Username
})
return participants, nil
return participants
}
func generateEmail(rctx request.CTX, fileAttachmentBackend filestore.FileBackend, channelExport *ChannelExport, templates *templates.Container, w io.Writer) (*model.AppError, int64) {
var warningCount int64
func generateEmail(rctx request.CTX, fileAttachmentBackend filestore.FileBackend, channelExport *ChannelExport, templates *templates.Container, w io.Writer) (int, error) {
var warningCount int
participantEmailAddresses := getParticipantEmails(channelExport)
// GlobalRelay expects the email to come from the person that initiated the conversation.
@@ -258,10 +264,10 @@ func generateEmail(rctx request.CTX, fileAttachmentBackend filestore.FileBackend
htmlBody, err := channelExportToHTML(rctx, channelExport, templates)
if err != nil {
return model.NewAppError("GlobalRelayExport", "ent.message_export.global_relay.generate_email.app_error", nil, "", http.StatusInternalServerError).Wrap(err), warningCount
return warningCount, fmt.Errorf("unable to generate eml file data: %w", err)
}
subject := fmt.Sprintf("Mattermost Compliance Export: %s", channelExport.ChannelName)
subject := fmt.Sprintf("Mattermost Compliance Export: %s", channelExport.ChannelDisplayName)
htmlMessage := "\r\n<html><body>" + htmlBody + "</body></html>"
txtBody, err := html2text.FromString(htmlBody)
@@ -278,9 +284,9 @@ func generateEmail(rctx request.CTX, fileAttachmentBackend filestore.FileBackend
"Auto-Submitted": {"auto-generated"},
"Precedence": {"bulk"},
GlobalRelayMsgTypeHeader: {"Mattermost"},
GlobalRelayChannelNameHeader: {encodeRFC2047Word(channelExport.ChannelName)},
GlobalRelayChannelNameHeader: {encodeRFC2047Word(channelExport.ChannelDisplayName)},
GlobalRelayChannelIDHeader: {encodeRFC2047Word(channelExport.ChannelId)},
GlobalRelayChannelTypeHeader: {encodeRFC2047Word(common_export.ChannelTypeDisplayName(channelExport.ChannelType))},
GlobalRelayChannelTypeHeader: {encodeRFC2047Word(shared.ChannelTypeDisplayName(channelExport.ChannelType))},
}
m := gomail.NewMessage(gomail.SetCharset("UTF-8"))
@@ -293,9 +299,10 @@ func generateEmail(rctx request.CTX, fileAttachmentBackend filestore.FileBackend
path := fileInfo.Path
m.Attach(fileInfo.Name, gomail.SetCopyFunc(func(writer io.Writer) error {
reader, appErr := fileAttachmentBackend.Reader(path)
if appErr != nil {
rctx.Logger().Warn("File not found for export", mlog.String("Filename", path))
var reader filestore.ReadCloseSeeker
reader, err = fileAttachmentBackend.Reader(path)
if err != nil {
rctx.Logger().Warn("File not found for export", mlog.String("filename", path))
warningCount += 1
return nil
}
@@ -303,7 +310,7 @@ func generateEmail(rctx request.CTX, fileAttachmentBackend filestore.FileBackend
_, err = io.Copy(writer, reader)
if err != nil {
return model.NewAppError("GlobalRelayExport", "ent.message_export.global_relay.attach_file.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return fmt.Errorf("unable to add attachment to the Global Relay export: %w", err)
}
return nil
}))
@@ -311,15 +318,15 @@ func generateEmail(rctx request.CTX, fileAttachmentBackend filestore.FileBackend
_, err = m.WriteTo(w)
if err != nil {
return model.NewAppError("GlobalRelayExport", "ent.message_export.global_relay.generate_email.app_error", nil, "", http.StatusInternalServerError).Wrap(err), warningCount
return warningCount, fmt.Errorf("unable to generate eml file data: %w", err)
}
return nil, warningCount
return warningCount, nil
}
func getParticipantEmails(channelExport *ChannelExport) []string {
participantEmails := make([]string, len(channelExport.Participants))
for i, participant := range channelExport.Participants {
participantEmails[i] = participant.Email
participantEmails := make([]string, 0, len(channelExport.Participants))
for _, participant := range channelExport.Participants {
participantEmails = append(participantEmails, participant.UserEmail)
}
return participantEmails
}
@@ -332,19 +339,15 @@ func fileInfoListBytes(fileInfoList []*model.FileInfo) int64 {
return totalBytes
}
func addPostToChannelExport(rctx request.CTX, channelExport *ChannelExport, post *model.MessageExport) {
userType := "user"
if post.IsBot {
userType = "bot"
}
func addPostToChannelExport(rctx request.CTX, channelExport *ChannelExport, post shared.PostExport) {
strPostProps := post.PostProps
bytPostProps := []byte(*strPostProps)
bytePostProps := []byte(*strPostProps)
// Added to show the username if overridden by a webhook or API integration
postUserName := ""
originalUsername := model.SafeDereference(post.Username)
postUserName := originalUsername
var postPropsLocal map[string]any
err := json.Unmarshal(bytPostProps, &postPropsLocal)
err := json.Unmarshal(bytePostProps, &postPropsLocal)
if err != nil {
rctx.Logger().Warn("Failed to unmarshal post Props into JSON. Ignoring username override.", mlog.Err(err))
} else {
@@ -352,67 +355,59 @@ func addPostToChannelExport(rctx request.CTX, channelExport *ChannelExport, post
postUserName = overrideUsername.(string)
}
if postUserName == "" {
if postUserName == originalUsername {
if overrideUsername, ok := postPropsLocal["webhook_display_name"]; ok {
postUserName = overrideUsername.(string)
}
}
}
element := Message{
SentTime: *post.PostCreateAt,
Message: *post.PostMessage,
SenderUserType: userType,
PostType: *post.PostType,
PostUsername: postUserName,
SenderUsername: *post.Username,
SenderEmail: *post.UserEmail,
PreviewsPost: post.PreviewID(),
}
element := postToMessage(post)
element.PostUsername = postUserName
channelExport.Messages = append(channelExport.Messages, element)
channelExport.EndTime = *post.PostCreateAt
channelExport.numUserMessages[*post.UserId] += 1
}
func addAttachmentToChannelExport(channelExport *ChannelExport, post *model.MessageExport, fileInfo *model.FileInfo, removeAttachments bool) {
var uploadElement Message
userType := "user"
if post.IsBot {
userType = "bot"
func postToMessage(post shared.PostExport) Message {
return Message{
Id: model.SafeDereference(post.PostId),
SentTime: model.SafeDereference(post.PostCreateAt),
SenderId: model.SafeDereference(post.UserId),
SenderUsername: model.SafeDereference(post.Username),
PostUsername: model.SafeDereference(post.Username),
SenderUserType: string(post.UserType),
SenderEmail: model.SafeDereference(post.UserEmail),
Message: post.Message,
PreviewsPost: post.PreviewID(),
UpdateAt: post.UpdateAt,
UpdateType: post.UpdatedType,
EditedNewMsgId: post.EditedNewMsgId,
}
if removeAttachments {
// add "post" message indicating that attachments were not sent
uploadElement = Message{
SentTime: fileInfo.CreateAt,
Message: fmt.Sprintf("Uploaded file '%s' (id '%s') was removed because it was too large to send.", fileInfo.Name, fileInfo.Id),
SenderUsername: *post.Username,
SenderUserType: userType,
SenderEmail: *post.UserEmail,
}
func addAttachmentsToChannelExport(channelExport *ChannelExport, post shared.PostExport,
attachmentStarts []*shared.FileUploadStartExport, attachmentDeletes []shared.PostExport, removeAttachments bool) {
for _, start := range attachmentStarts {
var message string
if removeAttachments {
message = fmt.Sprintf("Uploaded file %q (id '%s') was removed because it was too large to send.",
start.FileInfo.Name, start.FileInfo.Id)
} else {
channelExport.uploadedFiles = append(channelExport.uploadedFiles, start.FileInfo)
message = fmt.Sprintf("Uploaded file %s", start.FileInfo.Name)
}
if fileInfo.DeleteAt != 0 {
uploadElement.SentTime = fileInfo.DeleteAt
uploadElement.Message = fmt.Sprintf("Deleted file '%s' (id '%s') was removed because it was too large to send.", fileInfo.Name, fileInfo.Id)
}
} else {
channelExport.uploadedFiles = append(channelExport.uploadedFiles, fileInfo)
// add an implicit "post" to the export that includes the filename so GlobalRelay knows who uploaded each file
uploadElement = Message{
SentTime: fileInfo.CreateAt,
Message: fmt.Sprintf("Uploaded file %s", fileInfo.Name),
SenderUsername: *post.Username,
SenderUserType: userType,
SenderEmail: *post.UserEmail,
}
if fileInfo.DeleteAt != 0 {
uploadElement.SentTime = fileInfo.DeleteAt
uploadElement.Message = fmt.Sprintf("Deleted file %s", fileInfo.Name)
}
uploadElement := postToMessage(post)
uploadElement.Message = message
channelExport.Messages = append(channelExport.Messages, uploadElement)
}
channelExport.Messages = append(channelExport.Messages, uploadElement)
for _, deleted := range attachmentDeletes {
uploadElement := postToMessage(deleted)
uploadElement.Message = fmt.Sprintf("Deleted file %s", deleted.FileInfo.Name)
channelExport.Messages = append(channelExport.Messages, uploadElement)
}
}
func encodeRFC2047Word(s string) string {

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package global_relay_export
import (
"net/mail"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func AssertHeaderContains(t *testing.T, msg string, expected map[string]string) {
t.Helper()
m, err := mail.ReadMessage(strings.NewReader(msg))
require.NoError(t, err)
for k, v := range expected {
assert.Equal(t, v, m.Header.Get(k))
}
}
func CleanTestOutput(msg string) string {
msg = strings.Replace(msg, "=\r\n", "", -1)
msg = strings.Replace(msg, "\r\n", "\n", -1)
return msg
}

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

@@ -17,9 +17,15 @@ import (
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
)
func TimestampConvert(timestampMS int64) string {
return time.Unix(timestampMS/1000, 0).UTC().Format(time.RFC3339)
// for testing: (keep in case we need to be specific -- helps when you have joins and leaves within millis of each other)
//return fmt.Sprintf("%d", timestampMS)
}
func channelExportToHTML(rctx request.CTX, channelExport *ChannelExport, t *templates.Container) (string, error) {
durationMilliseconds := channelExport.EndTime - channelExport.StartTime
// TODO CHECK IF WE NEED THE MILISECONS HERE OR WE CAN ROUND IT DIRECTLY HERE
duration := time.Duration(durationMilliseconds) * time.Millisecond
var participantRowsBuffer bytes.Buffer
@@ -35,7 +41,9 @@ func channelExportToHTML(rctx request.CTX, channelExport *ChannelExport, t *temp
var messagesBuffer bytes.Buffer
sort.Slice(channelExport.Messages, func(i, j int) bool {
if channelExport.Messages[i].SentTime == channelExport.Messages[j].SentTime {
return !strings.HasPrefix(channelExport.Messages[i].Message, "Uploaded file") && !strings.HasPrefix(channelExport.Messages[i].Message, "Deleted file")
return !strings.HasPrefix(channelExport.Messages[i].Message, "Uploaded file") &&
!strings.HasPrefix(channelExport.Messages[i].Message, "Deleted file") &&
channelExport.Messages[i].UpdateType == ""
}
return channelExport.Messages[i].SentTime < channelExport.Messages[j].SentTime
})
@@ -50,13 +58,18 @@ func channelExportToHTML(rctx request.CTX, channelExport *ChannelExport, t *temp
data := templates.Data{
Props: map[string]any{
"ChannelName": channelExport.ChannelName,
"Started": time.Unix(channelExport.StartTime/1000, 0).UTC().Format(time.RFC3339),
"Ended": time.Unix(channelExport.EndTime/1000, 0).UTC().Format(time.RFC3339),
"Duration": durafmt.Parse(duration.Round(time.Minute)).String(),
"ParticipantRows": template.HTML(participantRowsBuffer.String()),
"Messages": template.HTML(messagesBuffer.String()),
"ExportDate": time.Unix(channelExport.ExportedOn/1000, 0).UTC().Format(time.RFC3339),
"TeamId": channelExport.TeamId,
"TeamName": channelExport.TeamName,
"TeamDisplayName": channelExport.TeamDisplayName,
"ChannelId": channelExport.ChannelId,
"ChannelName": channelExport.ChannelName,
"ChannelDisplayName": channelExport.ChannelDisplayName,
"Started": TimestampConvert(channelExport.StartTime),
"Ended": TimestampConvert(channelExport.EndTime),
"Duration": durafmt.Parse(duration.Round(time.Minute)).String(),
"ParticipantRows": template.HTML(participantRowsBuffer.String()),
"Messages": template.HTML(messagesBuffer.String()),
"ExportDate": TimestampConvert(channelExport.ExportedOn),
},
}
@@ -65,16 +78,16 @@ func channelExportToHTML(rctx request.CTX, channelExport *ChannelExport, t *temp
func participantToHTML(participant *ParticipantRow, t *templates.Container) (string, error) {
durationMilliseconds := participant.LeaveTime - participant.JoinTime
// TODO CHECK IF WE NEED THE MILISECONS HERE OR WE CAN ROUND IT DIRECTLY HERE
duration := time.Duration(durationMilliseconds) * time.Millisecond
data := templates.Data{
Props: map[string]any{
"UserId": participant.UserId,
"Username": participant.Username,
"UserType": participant.UserType,
"Email": participant.Email,
"Joined": time.Unix(participant.JoinTime/1000, 0).UTC().Format(time.RFC3339),
"Left": time.Unix(participant.LeaveTime/1000, 0).UTC().Format(time.RFC3339),
"Email": participant.UserEmail,
"Joined": TimestampConvert(participant.JoinTime),
"Left": TimestampConvert(participant.LeaveTime),
"Duration": durafmt.Parse(duration.Round(time.Minute)).String(),
"NumMessages": participant.MessagesSent,
},
@@ -90,14 +103,18 @@ func messageToHTML(message *Message, t *templates.Container) (string, error) {
}
data := templates.Data{
Props: map[string]any{
"SentTime": time.Unix(message.SentTime/1000, 0).UTC().Format(time.RFC3339),
"Username": message.SenderUsername,
"PostUsername": postUsername,
"UserType": message.SenderUserType,
"PostType": message.PostType,
"Email": message.SenderEmail,
"Message": message.Message,
"PreviewsPost": message.PreviewsPost,
"PostId": message.Id,
"SentTime": TimestampConvert(message.SentTime),
"UserId": message.SenderId,
"Username": message.SenderUsername,
"PostUsername": postUsername,
"UserType": message.SenderUserType,
"Email": message.SenderEmail,
"Message": message.Message,
"PreviewsPost": message.PreviewsPost,
"UpdateTime": TimestampConvert(message.UpdateAt),
"UpdateType": message.UpdateType,
"EditedNewMsgId": message.EditedNewMsgId,
},
}

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

@@ -6,34 +6,22 @@ package message_export
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"os"
"path"
"time"
"strconv"
"time"
"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/app"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
"github.com/mattermost/mattermost/server/v8/einterfaces"
ejobs "github.com/mattermost/mattermost/server/v8/einterfaces/jobs"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/actiance_export"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/csv_export"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/global_relay_export"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/shared"
)
const (
GlobalRelayExportFilename = "global-relay.zip"
)
const GlobalRelayExportFilename = "global-relay.zip"
type MessageExportInterfaceImpl struct {
Server *app.Server
@@ -56,7 +44,7 @@ func (m *MessageExportInterfaceImpl) StartSynchronizeJob(rctx request.CTX, expor
// if a valid export time was specified, put it in the job data
jobData := make(map[string]string)
if exportFromTimestamp >= 0 {
jobData[JobDataBatchStartTimestamp] = strconv.FormatInt(exportFromTimestamp, 10)
jobData[shared.JobDataBatchStartTime] = strconv.FormatInt(exportFromTimestamp, 10)
}
// passing nil for job data will cause the worker to inherit start time from previously successful job
@@ -85,32 +73,137 @@ func (m *MessageExportInterfaceImpl) StartSynchronizeJob(rctx request.CTX, expor
return job, nil
}
func (m *MessageExportInterfaceImpl) RunExport(rctx request.CTX, exportType string, since int64, limit int) (warningCount int64, appErr *model.AppError) {
if limit < 0 {
limit = math.MaxInt64
}
postsToExport, _, err := m.Server.Store().Compliance().MessageExport(rctx, model.MessageExportCursor{LastPostUpdateAt: since}, limit)
func RunBatch(rctx request.CTX, data shared.JobData, params shared.BackendParams) (shared.RunExportResults, shared.JobData, error) {
start := time.Now()
var err error
var res shared.RunExportResults
data, err = GetDataForBatch(rctx, data, params)
if err != nil {
return warningCount, model.NewAppError("RunExport", "ent.message_export.run_export.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
rctx.Logger().Debug("Found posts to export", mlog.Int("number_of_posts", len(postsToExport)))
fileBackend := m.Server.FileBackend()
templatesDir, ok := fileutils.FindDir("templates")
if !ok {
return warningCount, model.NewAppError("RunExport", "ent.compliance.run_export.template_watcher.appError", nil, "", http.StatusAccepted)
return res, data, err
}
t, err2 := templates.New(templatesDir)
if err2 != nil {
return warningCount, model.NewAppError("RunExport", "ent.compliance.run_export.template_watcher.appError", nil, "", http.StatusAccepted).Wrap(err2)
if data.Finished {
return res, data, nil
}
exportDirectory := getOutputDirectoryPath(since, model.GetMillis())
return runExportByType(rctx, exportType, postsToExport, exportDirectory, m.Server.Store(), fileBackend, fileBackend, t, m.Server.Config())
// Now write the data to the export type.
res, err = RunExportByType(rctx, DataToExportParams(data), params)
if err != nil {
return res, data, err
}
data.ProcessingPostsMs = append(data.ProcessingPostsMs, res.ProcessingPostsMs)
data.ProcessingXmlMs = append(data.ProcessingXmlMs, res.ProcessingXmlMs)
data.TransferringFilesMs = append(data.TransferringFilesMs, res.TransferringFilesMs)
data.TransferringZipMs = append(data.TransferringZipMs, res.TransferringZipMs)
data.TotalBatchMs = append(data.TotalBatchMs, time.Since(start).Milliseconds())
data.WarningCount += res.NumWarnings
data.BatchStartTime = data.BatchEndTime
return res, data, err
}
func runExportByType(rctx request.CTX, exportType string, postsToExport []*model.MessageExport, exportDirectory string, db store.Store, exportBackend filestore.FileBackend, fileAttachmentBackend filestore.FileBackend, htmlTemplates *templates.Container, config *model.Config) (warningCount int64, appErr *model.AppError) {
// GetDataForBatch gets the posts for this batch and updates JobData with the current state.
func GetDataForBatch(rctx request.CTX, data shared.JobData, params shared.BackendParams) (shared.JobData, error) {
start := time.Now()
var err error
// Using BatchSize+1 is a trick to test whether or not we've reached the final batch.
data.PostsToExport, data.Cursor, err = params.Store.Compliance().MessageExport(rctx, data.Cursor, data.BatchSize+1)
if err != nil {
return data, err
}
data.MessageExportMs = append(data.MessageExportMs, time.Since(start).Milliseconds())
if len(data.PostsToExport) == data.BatchSize+1 {
// We still have posts after this current batch.
// Remove the last post, we have to leave it for the next batch.
lastPostIdx := len(data.PostsToExport) - 1
data.PostsToExport = data.PostsToExport[:lastPostIdx]
lastPostIdx = len(data.PostsToExport) - 1
data.Cursor.LastPostUpdateAt = *data.PostsToExport[lastPostIdx].PostUpdateAt
data.Cursor.LastPostId = *data.PostsToExport[lastPostIdx].PostId
data.BatchEndTime = data.Cursor.LastPostUpdateAt
} else {
// We've reached the final batch; we need to include all join/leave events that occur after the lastpost.
// This will let us also pick up the joins/leaves that occur after lastPostUpdateAt but before JobEndTime.
data.BatchEndTime = data.JobEndTime
}
if len(data.PostsToExport) == 0 {
data.Finished = true
return data, nil
}
rctx.Logger().Debug("Found posts to export", mlog.Int("num_posts", len(data.PostsToExport)))
data.MessagesExported += len(data.PostsToExport)
data.BatchNumber++
data.BatchPath = shared.GetBatchPath(data.ExportDir, data.BatchStartTime, data.BatchEndTime, data.BatchNumber)
return data, nil
}
type ExportParams struct {
ExportType string
ChannelMetadata map[string]*shared.MetadataChannel
ChannelMemberHistories map[string][]*model.ChannelMemberHistoryResult
PostsToExport []*model.MessageExport
JobStartTime int64
BatchPath string
BatchStartTime int64
BatchEndTime int64
}
func DataToExportParams(data shared.JobData) ExportParams {
return ExportParams{
ExportType: data.ExportType,
ChannelMetadata: data.ChannelMetadata,
ChannelMemberHistories: data.ChannelMemberHistories,
PostsToExport: data.PostsToExport,
JobStartTime: data.JobStartTime,
BatchPath: data.BatchPath,
BatchStartTime: data.BatchStartTime,
BatchEndTime: data.BatchEndTime,
}
}
func RunExportByType(rctx request.CTX, p ExportParams, b shared.BackendParams) (results shared.RunExportResults, err error) {
preparePosts(rctx, p.PostsToExport)
exportParams := shared.ExportParams{
ExportType: p.ExportType,
ChannelMetadata: p.ChannelMetadata,
Posts: p.PostsToExport,
ChannelMemberHistories: p.ChannelMemberHistories,
JobStartTime: p.JobStartTime,
BatchPath: p.BatchPath,
BatchStartTime: p.BatchStartTime,
BatchEndTime: p.BatchEndTime,
Config: b.Config,
Db: b.Store,
FileAttachmentBackend: b.FileAttachmentBackend,
ExportBackend: b.ExportBackend,
Templates: b.HtmlTemplates,
}
switch p.ExportType {
case model.ComplianceExportTypeCsv:
rctx.Logger().Debug("Exporting CSV")
return csv_export.CsvExport(rctx, exportParams)
case model.ComplianceExportTypeActiance:
rctx.Logger().Debug("Exporting Actiance")
return actiance_export.ActianceExport(rctx, exportParams)
case model.ComplianceExportTypeGlobalrelay, model.ComplianceExportTypeGlobalrelayZip:
rctx.Logger().Debug("Exporting GlobalRelay")
return global_relay_export.GlobalRelayExport(rctx, exportParams)
default:
return results, errors.New("Unknown output format: " + p.ExportType)
}
}
func preparePosts(rctx request.CTX, postsToExport []*model.MessageExport) {
// go through all the posts and if the post's props contain 'from_bot' - override the IsBot field, since it's possible that the sender is not a user, but was a Bot and vise-versa
for _, post := range postsToExport {
if post.PostProps != nil {
@@ -178,57 +271,4 @@ func runExportByType(rctx request.CTX, exportType string, postsToExport []*model
post.PostCreateAt = new(int64)
}
}
switch exportType {
case model.ComplianceExportTypeCsv:
rctx.Logger().Debug("Exporting CSV")
return csv_export.CsvExport(rctx, postsToExport, db, exportBackend, fileAttachmentBackend, exportDirectory)
case model.ComplianceExportTypeActiance:
rctx.Logger().Debug("Exporting Actiance")
return actiance_export.ActianceExport(rctx, postsToExport, db, exportBackend, fileAttachmentBackend, exportDirectory)
case model.ComplianceExportTypeGlobalrelay, model.ComplianceExportTypeGlobalrelayZip:
rctx.Logger().Debug("Exporting GlobalRelay")
f, err := os.CreateTemp("", "")
if err != nil {
return warningCount, model.NewAppError("RunExport", "ent.compliance.global_relay.open_temporary_file.appError", nil, "", http.StatusAccepted).Wrap(err)
}
defer f.Close()
defer os.Remove(f.Name())
attachmentsRemovedPostIDs, warnings, appErr := global_relay_export.GlobalRelayExport(rctx, postsToExport, db, fileAttachmentBackend, f, htmlTemplates)
if appErr != nil {
return warningCount, appErr
}
warningCount = warnings
_, err = f.Seek(0, 0)
if err != nil {
return warningCount, model.NewAppError("RunExport", "ent.compliance.global_relay.rewind_temporary_file.appError", nil, "", http.StatusAccepted).Wrap(err)
}
if exportType == model.ComplianceExportTypeGlobalrelayZip {
// Try to disable the write timeout for the potentially big export file.
_, nErr := filestore.TryWriteFileContext(rctx.Context(), exportBackend, f, path.Join(exportDirectory, GlobalRelayExportFilename))
if nErr != nil {
return warningCount, model.NewAppError("runExportByType", "ent.compliance.global_relay.write_file.appError", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
} else {
appErr = global_relay_export.Deliver(f, config)
if appErr != nil {
return warningCount, appErr
}
}
if len(attachmentsRemovedPostIDs) > 0 {
rctx.Logger().Debug("Global Relay Attachments Removed because they were too large to send to Global Relay", mlog.Array("attachment_ids", attachmentsRemovedPostIDs))
description := fmt.Sprintf("Attachments to post IDs %v were removed because they were too large to send to Global Relay.", attachmentsRemovedPostIDs)
appErr = model.NewAppError("RunExport", "ent.compliance.global_relay.attachments_removed.appError", map[string]any{"Description": description}, description, http.StatusAccepted)
return warningCount, appErr
}
default:
err := errors.New("Unknown output format " + exportType)
return warningCount, model.NewAppError("RunExport", "ent.compliance.bad_export_type.appError", map[string]any{"ExportType": exportType}, "", http.StatusBadRequest).Wrap(err)
}
return warningCount, nil
}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@@ -39,7 +39,7 @@ func (s *MessageExportScheduler) NextScheduleTime(cfg *model.Config, now time.Ti
if err != nil {
s.jobServer.Logger().Error(
"Cannot determine next schedule time for message export. DailyRunTime config value is invalid.",
mlog.String("DailyRunTime", *cfg.MessageExportSettings.DailyRunTime),
mlog.String("daily_run_time", *cfg.MessageExportSettings.DailyRunTime),
)
return nil
}

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

@@ -0,0 +1,543 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"sort"
"github.com/mattermost/mattermost/server/public/model"
)
type UserType string
const (
User UserType = "user"
Bot UserType = "bot"
)
type PostExport struct {
model.MessageExport // the MessageExport that this PostExport is providing more information for
UserType UserType // the type of the person that sent the post: "user" or "bot"
// Allows us to differentiate between:
// - "EditedOriginalMsg": the newly created message (new Id), which holds the pre-edited message contents. The
// "EditedNewMsgId" field will point to the message (original Id) which has the post-edited message content.
// - "EditedNewMsg": the post-edited message content. This is confusing, so be careful: in the db, this EditedNewMsg
// is actually the original messageId because we wanted an edited message to have the same messageId as the
// pre-edited message. But for the purposes of exporting and to keep the mental model clear for end-users, we are
// calling this the EditedNewMsg and EditedNewMsgId, because this will hold the NEW post-edited message contents,
// and that's what's important to the end-user viewing the export.
// - "UpdatedNoMsgChange": the message content hasn't changed, but the post was updated for some reason (reaction,
// replied-to, a reply was edited, a reply was deleted (as of 10.2), perhaps other reasons)
// - "Deleted": the message was deleted.
// - "FileDeleted": this message is recording that a file was deleted.
UpdatedType PostUpdatedType
UpdateAt int64 // if this is an updated post, this is the updated time (same as deleted time for deleted posts).
// when a message is edited, the EditedOriginalMsg points to the message Id that now has the newly edited message.
EditedNewMsgId string
Message string // the text body of the post
PreviewsPost string // the post id of the post that is previewed by the permalink preview feature
AttachmentCreates []*FileUploadStartExport // the post's attachments that were uploaded this export period
AttachmentDeletes []PostExport // the post's attachments that were deleted
FileInfo *model.FileInfo // if this was a file PostExport, FileInfo will contain that info. Otherwise, nil.
}
type FileUploadStartExport struct {
model.MessageExport // the post that this upload was attached to
UserEmail string // the email of the person that sent the file
UploadStartTime int64 // utc timestamp (seconds), time at which the user started the upload. Example: 1366611728
FileInfo *model.FileInfo
}
type FileUploadStopExport struct {
model.MessageExport // the post that this upload was attached to
UserEmail string // the email of the person that sent the file
UploadStopTime int64 // utc timestamp (seconds), time at which the user finished the upload. Example: 1366611728
Status string // set to either "Completed" or "Failed" depending on the outcome of the upload operation
FileInfo *model.FileInfo
}
type ChannelExport struct {
ChannelId string
ChannelType model.ChannelType
ChannelName string
DisplayName string
StartTime int64 // utc timestamp (milliseconds), start of export period or create time of channel, whichever is greater.
EndTime int64 // utc timestamp (milliseconds), end of export period or delete time of channel, whichever is lesser.
Posts []PostExport
Files []*model.FileInfo
DeletedFiles []PostExport
UploadStarts []*FileUploadStartExport
UploadStops []*FileUploadStopExport
JoinEvents []JoinExport // start with a list of all users who were present in the channel during the export period
LeaveEvents []LeaveExport // finish with a list of all users who were present in the channel during the export period
// Used by csv, ignored by others
TeamId string
TeamName string
TeamDisplayName string
}
type JoinExport struct {
UserId string
Username string
UserEmail string // the email of the person that joined the channel
UserType UserType // the type of the user that joined the channel
JoinTime int64 // utc timestamp (seconds), time at which the user joined. Example: 1366611728
// when the user left (or batch endTime if they didn't leave). Only used by GlobalRelay
LeaveTime int64
}
type LeaveExport struct {
UserId string
Username string
UserEmail string // the email of the person that left the channel
UserType UserType // the type of the user that left the channel
LeaveTime int64 // utc timestamp (seconds), time at which the user left. Example: 1366611728
// ClosedOut indicates this is a "leave" event created by closing out the channel at the end of an export period.
// Actiance requires all users to be closed out at the end of an export period (each join has a matching leave).
ClosedOut bool
}
type GenericExportData struct {
Exports []ChannelExport
Metadata Metadata
Results RunExportResults
}
// GetGenericExportData assembles all the data in an exportType-agnostic way. Each exportType will process this data into
// the specific format they need to export.
func GetGenericExportData(p ExportParams) (GenericExportData, error) {
// postAuthorsByChannel is a map so that we don't store duplicate authors
postAuthorsByChannel := make(map[string]map[string]ChannelMember)
metadata := Metadata{
Channels: p.ChannelMetadata,
MessagesCount: 0,
AttachmentsCount: 0,
StartTime: p.BatchStartTime,
EndTime: p.BatchEndTime,
}
var results RunExportResults
channelsInThisBatch := make(map[string]bool)
postsByChannel := make(map[string][]PostExport)
filesByChannel := make(map[string][]*model.FileInfo)
uploadStartsByChannel := make(map[string][]*FileUploadStartExport)
uploadStopsByChannel := make(map[string][]*FileUploadStopExport)
deletedFilesByChannel := make(map[string][]PostExport)
processPostAttachments := func(post *model.MessageExport, postExport PostExport, originalPostThatWillBeDeletedLater bool) error {
// originalPostThatWillBeDeletedLater means we are recording this message's original file starts and stops,
// before it was deleted (we'll record that next call to this function)
//
// NOTE: there is an edge case here: the original post is not deleted but the attachment has been deleted.
// See the note in the postToAttachmentsEntries func.
channelId := *post.ChannelId
uploadedFiles, startUploads, stopUploads, deleteFileMessages, err :=
postToAttachmentsEntries(post, p.Db, originalPostThatWillBeDeletedLater)
if err != nil {
return err
}
uploadStartsByChannel[channelId] = append(uploadStartsByChannel[channelId], startUploads...)
uploadStopsByChannel[channelId] = append(uploadStopsByChannel[channelId], stopUploads...)
deletedFilesByChannel[channelId] = append(deletedFilesByChannel[channelId], deleteFileMessages...)
filesByChannel[channelId] = append(filesByChannel[channelId], uploadedFiles...)
postExport.AttachmentCreates = startUploads
postExport.AttachmentDeletes = deleteFileMessages
postsByChannel[channelId] = append(postsByChannel[channelId], postExport)
results.UploadedFiles += len(startUploads)
results.DeletedFiles += len(deleteFileMessages)
// only count uploaded files (not deleted files)
if err := metadata.UpdateCounts(channelId, 1, len(startUploads)); err != nil {
return err
}
return nil
}
for _, post := range p.Posts {
channelId := *post.ChannelId
channelsInThisBatch[channelId] = true
// Was the post deleted (not an edited post), and originally posted during the current job window?
// If so, we need to record it. It may actually belong in an earlier batch, but there's no way to know that
// before now because of the way we export posts (by updateAt).
if IsDeletedMsg(post) && !isEditedOriginalMsg(post) && *post.PostCreateAt >= p.JobStartTime {
results.CreatedPosts++
postExport := createdPostToExportEntry(post)
if err := processPostAttachments(post, postExport, true); err != nil {
return GenericExportData{}, err
}
}
var postExport PostExport
postExport, results = getPostExport(post, results)
if err := processPostAttachments(post, postExport, false); err != nil {
return GenericExportData{}, err
}
if _, ok := postAuthorsByChannel[channelId]; !ok {
postAuthorsByChannel[channelId] = make(map[string]ChannelMember)
}
postAuthorsByChannel[channelId][*post.UserId] = ChannelMember{
UserId: *post.UserId,
Email: *post.UserEmail,
Username: *post.Username,
IsBot: post.IsBot,
}
}
// If the channel is not in channelsInThisBatch (i.e. if it didn't have a post), we need to check if it had
// user activity between this batch's startTime-endTime. If so, add it to the channelsInThisBatch.
for id := range p.ChannelMetadata {
if !channelsInThisBatch[id] {
if ChannelHasActivity(p.ChannelMemberHistories[id], p.BatchStartTime, p.BatchEndTime) {
channelsInThisBatch[id] = true
}
}
}
// Build the channel exports for the channels that had post or user join/leave activity this batch.
channelExports := make([]ChannelExport, 0, len(channelsInThisBatch))
for id := range channelsInThisBatch {
c := metadata.Channels[id]
joinEvents, leaveEvents := getJoinsAndLeaves(p.BatchStartTime, p.BatchEndTime,
p.ChannelMemberHistories[id], postAuthorsByChannel[id])
// We don't have teamName and teamDisplayName from the channelMetaData, but we have it from MessageExport.
// However, if we don't have posts for this channel (only joins and leaves), then we don't have it at all.
var teamName, teamDisplayName string
if posts, ok := postsByChannel[id]; ok {
if len(posts) > 0 {
teamName = model.SafeDereference(posts[0].TeamName)
teamDisplayName = model.SafeDereference(posts[0].TeamDisplayName)
}
}
channelExports = append(channelExports, ChannelExport{
ChannelId: c.ChannelId,
ChannelType: c.ChannelType,
ChannelName: c.ChannelName,
DisplayName: c.ChannelDisplayName,
StartTime: p.BatchStartTime,
EndTime: p.BatchEndTime,
Posts: postsByChannel[id],
Files: filesByChannel[id],
DeletedFiles: deletedFilesByChannel[id],
UploadStarts: uploadStartsByChannel[id],
UploadStops: uploadStopsByChannel[id],
JoinEvents: joinEvents,
LeaveEvents: leaveEvents,
TeamId: model.SafeDereference(c.TeamId),
TeamName: teamName,
TeamDisplayName: teamDisplayName,
})
results.Joins += len(joinEvents)
results.Leaves += len(leaveEvents)
}
return GenericExportData{channelExports, metadata, results}, nil
}
// postToAttachmentsEntries returns every fileInfo as uploadedFiles. It also adds each file into the lists:
//
// startUploads, stopUploads, and deleteFileMessages (for ActianceExport).
// If onlyDeleted = true, only export the deleted entry (if it exists).
func postToAttachmentsEntries(post *model.MessageExport, db MessageExportStore, ignoreDeleted bool) (
uploadedFiles []*model.FileInfo, startUploads []*FileUploadStartExport, stopUploads []*FileUploadStopExport, deleteFileMessages []PostExport, err error) {
// if the post included any files, we need to add special elements to the export.
//
// NOTE: there is an edge case here: the original post is not deleted but the attachment has been deleted.
// 1. If the attachment is deleted sometime later in the future but the post has not been updated (updateAt == createAt)
// then we won't ever get here, there's nothing we can do.
// 2. If the attachment is deleted within this export period, or the post is updated this export period and we
// now see that the attachment is deleted, then we have to output 2 files here:
// a) the original file attachment
// b) the deleted file attachment
// These have to be added the start/stop and deletedFileMessages
if len(post.PostFileIds) == 0 {
return
}
uploadedFiles, err = db.FileInfo().GetForPost(*post.PostId, true, true, false)
if err != nil {
return
}
for _, fileInfo := range uploadedFiles {
if fileInfo.DeleteAt > 0 && !ignoreDeleted {
deleteFileMessages = append(deleteFileMessages, deleteFileToExportEntry(post, fileInfo))
// this was a deleted file, so do not record its start and stop. If the original message was sent in this
// batch, the file transfer will have been exported earlier up when the original message was exported.
//
// However, because of the edge case above, we still need to record start and stop if the post is not deleted.
if IsDeletedMsg(post) {
continue
} // not deleted, so need to add start and stop below.
}
// insert a record of the file upload into the export file
// path to exported file is relative to the fileAttachmentFilestore root,
// which could be different from the exportFilestore root
startUploads = append(startUploads, &FileUploadStartExport{
MessageExport: *post,
UserEmail: *post.UserEmail,
UploadStartTime: *post.PostCreateAt,
FileInfo: fileInfo,
})
stopUploads = append(stopUploads, &FileUploadStopExport{
MessageExport: *post,
UserEmail: *post.UserEmail,
UploadStopTime: *post.PostCreateAt,
Status: "Completed",
FileInfo: fileInfo,
})
}
return
}
func getPostExport(post *model.MessageExport, results RunExportResults) (PostExport, RunExportResults) {
// We have three "kinds" of posts:
// (using "1" and "2" for simplicity)
// - created: Id = new, CreateAt = 1, UpdateAt = 1, DeleteAt = 0
// - deleted: Id = orig, CreateAt = orig, UpdateAt = 2, DeleteAt = 2, props: deleteBy
// - edited: old post gets "created": Id = new, CreateAt = 1, UpdateAt = 2, DeleteAt = 2, originalId: orig
// existing post modified: Id = orig, CreateAt = 1, UpdateAt = 2, DeleteAt = 0
//
// We also have other ways for a post to be updated:
// - a root post in a thread is replied to, when a reply is edited, or (as of 10.2) when a reply is deleted
if isEditedOriginalMsg(post) {
// Post has been edited. This is the original message.
results.EditedOrigMsgPosts++
return editedOriginalMsgToExportEntry(post), results
} else if IsDeletedMsg(post) {
// Post is deleted
results.DeletedPosts++
return deletedPostToExportEntry(post, "delete "+*post.PostMessage), results
} else if *post.PostUpdateAt > *post.PostCreateAt {
// Post has been updated. But what kind?
if model.SafeDereference(post.PostEditAt) > 0 {
// This is an edited post.
results.EditedNewMsgPosts++
return editedNewMsgToExportEntry(post), results
}
// This is just an updated post (e.g. reaction)
results.UpdatedPosts++
return updatedPostToExportEntry(post), results
}
// Post is newly created:
// *post.PostCreateAt == *post.PostUpdateAt && (post.PostDeleteAt == nil || *post.PostDeleteAt == 0)
// but also fallback to this in case there is missing data, which is better than not exporting anything.
results.CreatedPosts++
return createdPostToExportEntry(post), results
}
func getJoinsAndLeaves(startTime int64, endTime int64, channelMembersHistory []*model.ChannelMemberHistoryResult,
postAuthors map[string]ChannelMember) ([]JoinExport, []LeaveExport) {
var leaveEvents []LeaveExport
joins, leaves := GetJoinsAndLeavesForChannel(startTime, endTime, channelMembersHistory, postAuthors)
joinsById := make(map[string]JoinExport, len(joins))
type StillMemberInfo struct {
time int64
userType UserType
userId string
username string
}
stillMember := map[string]StillMemberInfo{}
for _, join := range joins {
userType := User
if join.IsBot {
userType = Bot
}
joinsById[join.UserId] = JoinExport{
UserId: join.UserId,
Username: join.Username,
UserEmail: join.Email,
JoinTime: join.Datetime,
UserType: userType,
LeaveTime: endTime,
}
if value, ok := stillMember[join.Email]; !ok {
stillMember[join.Email] = StillMemberInfo{time: join.Datetime, userType: userType, userId: join.UserId, username: join.Username}
} else if join.Datetime > value.time {
stillMember[join.Email] = StillMemberInfo{time: join.Datetime, userType: userType, userId: join.UserId, username: join.Username}
}
}
for _, leave := range leaves {
userType := User
if leave.IsBot {
userType = Bot
}
leaveEvents = append(leaveEvents, LeaveExport{
UserId: leave.UserId,
Username: leave.Username,
UserEmail: leave.Email,
LeaveTime: leave.Datetime,
UserType: userType,
})
if leave.Datetime > stillMember[leave.Email].time {
delete(stillMember, leave.Email)
}
// record their leave in their initial join
if join, ok := joinsById[leave.UserId]; ok {
join.LeaveTime = leave.Datetime
joinsById[leave.UserId] = join
}
}
// Closing-out the channel for Actiance (each join must have a matching leave).
for email := range stillMember {
leaveEvents = append(leaveEvents, LeaveExport{
UserId: stillMember[email].userId,
Username: stillMember[email].username,
LeaveTime: endTime,
UserEmail: email,
UserType: stillMember[email].userType,
ClosedOut: true,
})
}
joinEvents := make([]JoinExport, 0, len(joinsById))
for _, v := range joinsById {
joinEvents = append(joinEvents, v)
}
sort.Slice(joinEvents, func(i, j int) bool {
if joinEvents[i].JoinTime == joinEvents[j].JoinTime {
return joinEvents[i].UserEmail < joinEvents[j].UserEmail
}
return joinEvents[i].JoinTime < joinEvents[j].JoinTime
})
sort.Slice(leaveEvents, func(i, j int) bool {
if leaveEvents[i].LeaveTime == leaveEvents[j].LeaveTime {
return leaveEvents[i].UserEmail < leaveEvents[j].UserEmail
}
return leaveEvents[i].LeaveTime < leaveEvents[j].LeaveTime
})
return joinEvents, leaveEvents
}
func isEditedOriginalMsg(post *model.MessageExport) bool {
return model.SafeDereference(post.PostDeleteAt) > 0 && model.SafeDereference(post.PostOriginalId) != ""
}
func createdPostToExportEntry(post *model.MessageExport) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
Message: *post.PostMessage,
UserType: userType,
PreviewsPost: post.PreviewID(),
}
}
func deletedPostToExportEntry(post *model.MessageExport, newMsg string) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: *post.PostDeleteAt,
UpdatedType: Deleted,
Message: newMsg,
UserType: userType,
PreviewsPost: post.PreviewID(),
}
}
func editedOriginalMsgToExportEntry(post *model.MessageExport) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: *post.PostUpdateAt,
UpdatedType: EditedOriginalMsg,
Message: *post.PostMessage,
UserType: userType,
PreviewsPost: post.PreviewID(),
EditedNewMsgId: *post.PostOriginalId,
}
}
func editedNewMsgToExportEntry(post *model.MessageExport) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: *post.PostUpdateAt,
UpdatedType: EditedNewMsg,
Message: *post.PostMessage,
UserType: userType,
PreviewsPost: post.PreviewID(),
}
}
func updatedPostToExportEntry(post *model.MessageExport) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: *post.PostUpdateAt,
UpdatedType: UpdatedNoMsgChange,
Message: *post.PostMessage,
UserType: userType,
PreviewsPost: post.PreviewID(),
}
}
func deleteFileToExportEntry(post *model.MessageExport, fileInfo *model.FileInfo) PostExport {
userType := User
if post.IsBot {
userType = Bot
}
return PostExport{
MessageExport: *post,
UpdateAt: fileInfo.DeleteAt,
UpdatedType: FileDeleted,
Message: "delete " + fileInfo.Path,
UserType: userType,
PreviewsPost: post.PreviewID(),
FileInfo: fileInfo,
}
}
func UploadStartToExportEntry(u *FileUploadStartExport) PostExport {
userType := User
if u.IsBot {
userType = Bot
}
return PostExport{
MessageExport: u.MessageExport,
UpdateAt: u.FileInfo.UpdateAt,
UserType: userType,
FileInfo: u.FileInfo,
}
}

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

@@ -0,0 +1,523 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
)
func postToMessageExport(t *testing.T, p *model.Post, u *model.User, c *model.Channel, team *model.Team) model.MessageExport {
t.Helper()
props, err := json.Marshal(p.GetProps())
assert.NoError(t, err)
return model.MessageExport{
TeamId: &team.Id,
TeamName: &team.Name,
TeamDisplayName: &team.DisplayName,
ChannelId: &c.Id,
ChannelName: &c.Name,
ChannelDisplayName: &c.DisplayName,
ChannelType: &c.Type,
UserId: &u.Id,
UserEmail: model.NewPointer(u.Email),
Username: &u.Username,
IsBot: false,
PostId: model.NewPointer(p.Id),
PostCreateAt: model.NewPointer(p.CreateAt),
PostUpdateAt: &p.UpdateAt,
PostDeleteAt: &p.DeleteAt,
PostEditAt: &p.EditAt,
PostMessage: &p.Message,
PostType: &p.Type,
PostRootId: &p.RootId,
PostProps: model.NewPointer(string(props)),
PostOriginalId: &p.OriginalId,
PostFileIds: p.FileIds,
}
}
func Test_getPostExport(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
jobs.DefaultWatcherPollingInterval = 100
th := api4.SetupEnterprise(t).InitBasic()
th.App.Srv().SetLicense(model.NewTestLicense("message_export"))
defer th.TearDown()
// the post exports from the db will be random (because they all have the same updateAt), so do it a few times
for i := 0; i < 10; i++ {
time.Sleep(time.Millisecond)
start := model.GetMillis()
count, err := th.App.Srv().Store().Post().AnalyticsPostCount(&model.PostCountOptions{ExcludeSystemPosts: true, SincePostID: "", SinceUpdateAt: start})
require.NoError(t, err)
require.Equal(t, 0, int(count))
var posts []*model.Post
// 0 - post edited with 3 simultaneous posts in-between - forward
// original post with edited message
originalPost, err := th.App.Srv().Store().Post().Save(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "message 0",
})
require.NoError(t, err)
require.NotEqual(t, 0, originalPost.UpdateAt, "originalPost's updateAt was zero, test 1")
posts = append(posts, originalPost)
// If we don't sleep, the two messages might not have different CreateAt and UpdateAts
time.Sleep(time.Millisecond)
// 1 - edited post
post, err := th.App.Srv().Store().Post().Update(th.Context, &model.Post{
Id: originalPost.Id,
CreateAt: originalPost.CreateAt,
EditAt: model.GetMillis(),
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: "edited message 0",
}, originalPost)
require.NoError(t, err)
require.NotEqual(t, 0, originalPost.UpdateAt, "originalPost's updateAt was zero, test 2")
require.NotEqual(t, 0, post.UpdateAt, "edited post's updateAt was zero, test 2")
posts = append(posts, post)
simultaneous := post.UpdateAt
// Add 8 other posts at the same updateAt
for j := 1; j <= 8; j++ {
// 2 - post 1 at same updateAt
post, err = th.App.Srv().Store().Post().Save(th.Context, &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Message: fmt.Sprintf("message %d", j),
CreateAt: simultaneous,
})
require.NoError(t, err)
require.NotEqual(t, 0, post.UpdateAt)
posts = append(posts, post)
}
// Use the config fallback for simplicity
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.MessageExportSettings.EnableExport = true
*cfg.MessageExportSettings.ExportFromTimestamp = start
*cfg.MessageExportSettings.BatchSize = 10
})
// the messages can be in any order because all have equal `updateAt`s
expectedExports := []PostExport{
{
MessageExport: postToMessageExport(t, posts[0], th.BasicUser, th.BasicChannel, th.BasicTeam),
UserType: "user",
Message: posts[0].Message,
UpdateAt: posts[1].UpdateAt, // the edit update at
UpdatedType: EditedOriginalMsg,
EditedNewMsgId: posts[1].Id,
},
{
MessageExport: postToMessageExport(t, posts[1], th.BasicUser, th.BasicChannel, th.BasicTeam),
UserType: "user",
Message: posts[1].Message,
UpdateAt: posts[1].UpdateAt,
UpdatedType: EditedNewMsg,
},
}
for j := 2; j < 10; j++ {
expectedExports = append(expectedExports, PostExport{
MessageExport: postToMessageExport(t, posts[j], th.BasicUser, th.BasicChannel, th.BasicTeam),
UserType: "user",
Message: posts[j].Message,
})
}
actualMessageExports, _, err := th.App.Srv().Store().Compliance().MessageExport(th.Context, model.MessageExportCursor{
LastPostUpdateAt: start,
UntilUpdateAt: model.GetMillis(),
}, 10)
require.NoError(t, err)
require.Len(t, actualMessageExports, 10)
for _, export := range actualMessageExports {
require.NotEqual(t, 0, *export.PostUpdateAt)
}
results := RunExportResults{}
var actualExports []PostExport
for _, m := range actualMessageExports {
var postExport PostExport
postExport, results = getPostExport(m, results)
actualExports = append(actualExports, postExport)
}
require.ElementsMatch(t, expectedExports, actualExports, fmt.Sprintf("batch %d", i))
}
}
func TestPostToAttachmentsEntries(t *testing.T) {
chanTypeDirect := model.ChannelTypeDirect
tt := []struct {
name string
post model.MessageExport
attachments []*model.FileInfo
expectedStarts []*FileUploadStartExport
expectedStops []*FileUploadStopExport
expectedFileInfos []*model.FileInfo
expectedDeleteFileMessages []PostExport
ignoreDeleted bool
expectError bool
}{
{
name: "no-attachments",
post: model.MessageExport{
ChannelId: model.NewPointer("Test"),
ChannelDisplayName: model.NewPointer("Test"),
PostCreateAt: model.NewPointer(int64(1)),
PostMessage: model.NewPointer("Some message"),
UserEmail: model.NewPointer("test@test.com"),
UserId: model.NewPointer("test"),
Username: model.NewPointer("test"),
ChannelType: &chanTypeDirect,
},
attachments: nil,
expectedStarts: nil,
expectedStops: nil,
expectedFileInfos: nil,
expectedDeleteFileMessages: nil,
expectError: false,
},
{
name: "one-attachment",
post: model.MessageExport{
PostId: model.NewPointer("test"),
ChannelId: model.NewPointer("Test"),
ChannelDisplayName: model.NewPointer("Test"),
PostCreateAt: model.NewPointer(int64(1)),
PostMessage: model.NewPointer("Some message"),
UserEmail: model.NewPointer("test@test.com"),
UserId: model.NewPointer("test"),
Username: model.NewPointer("test"),
ChannelType: &chanTypeDirect,
PostFileIds: []string{"12345"},
},
attachments: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt"},
},
expectedStarts: []*FileUploadStartExport{
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt"}},
},
expectedStops: []*FileUploadStopExport{
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt"}, Status: "Completed"},
},
expectedFileInfos: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt"},
},
expectedDeleteFileMessages: nil,
expectError: false,
},
{
name: "two-attachment",
post: model.MessageExport{
PostId: model.NewPointer("test"),
ChannelId: model.NewPointer("Test"),
ChannelDisplayName: model.NewPointer("Test"),
PostCreateAt: model.NewPointer(int64(1)),
PostMessage: model.NewPointer("Some message"),
UserEmail: model.NewPointer("test@test.com"),
UserId: model.NewPointer("test"),
Username: model.NewPointer("test"),
ChannelType: &chanTypeDirect,
PostFileIds: []string{"12345", "54321"},
},
attachments: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt"},
{Name: "test2", Id: "54321", Path: "filename2.txt"},
},
expectedStarts: []*FileUploadStartExport{
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt"}},
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Id: "54321", Name: "test2", Path: "filename2.txt"}},
},
expectedStops: []*FileUploadStopExport{
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt"}, Status: "Completed"},
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Id: "54321", Name: "test2", Path: "filename2.txt"}, Status: "Completed"},
},
expectedFileInfos: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt"},
{Name: "test2", Id: "54321", Path: "filename2.txt"},
},
expectedDeleteFileMessages: nil,
expectError: false,
},
{
name: "one-attachment-deleted",
post: model.MessageExport{
PostId: model.NewPointer("test"),
ChannelId: model.NewPointer("Test"),
ChannelDisplayName: model.NewPointer("Test"),
PostCreateAt: model.NewPointer(int64(1)),
PostDeleteAt: model.NewPointer(int64(2)),
PostMessage: model.NewPointer("Some message"),
UserEmail: model.NewPointer("test@test.com"),
UserId: model.NewPointer("test"),
Username: model.NewPointer("test"),
ChannelType: &chanTypeDirect,
PostFileIds: []string{"12345", "54321"},
},
attachments: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2},
},
expectedStarts: []*FileUploadStartExport{
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt", DeleteAt: 2}},
},
expectedStops: []*FileUploadStopExport{
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt", DeleteAt: 2}, Status: "Completed"},
},
expectedFileInfos: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2},
},
expectedDeleteFileMessages: []PostExport{
{
UserType: "user",
UpdatedType: FileDeleted,
UpdateAt: 2,
Message: "delete " + "filename.txt",
FileInfo: &model.FileInfo{Id: "12345", Name: "test", Path: "filename.txt", DeleteAt: 2},
},
},
expectError: false,
},
{
name: "one-attachment-deleted, ignore it (only record the creation)",
post: model.MessageExport{
PostId: model.NewPointer("test"),
ChannelId: model.NewPointer("Test"),
ChannelDisplayName: model.NewPointer("Test"),
PostCreateAt: model.NewPointer(int64(1)),
PostDeleteAt: model.NewPointer(int64(2)),
PostMessage: model.NewPointer("Some message"),
UserEmail: model.NewPointer("test@test.com"),
UserId: model.NewPointer("test"),
Username: model.NewPointer("test"),
ChannelType: &chanTypeDirect,
PostFileIds: []string{"12345", "54321"},
},
attachments: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2},
},
expectedStarts: []*FileUploadStartExport{
{UserEmail: "test@test.com", UploadStartTime: 1,
FileInfo: &model.FileInfo{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2}},
},
expectedStops: []*FileUploadStopExport{
{UserEmail: "test@test.com", UploadStopTime: 1,
FileInfo: &model.FileInfo{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2}, Status: "Completed"},
},
expectedFileInfos: []*model.FileInfo{
{Name: "test", Id: "12345", Path: "filename.txt", DeleteAt: 2},
},
expectedDeleteFileMessages: nil,
ignoreDeleted: true,
expectError: false,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
mockStore := &storetest.Store{}
defer mockStore.AssertExpectations(t)
if len(tc.attachments) > 0 {
call := mockStore.FileInfoStore.On("GetForPost", *tc.post.PostId, true, true, false)
call.Run(func(args mock.Arguments) {
call.Return(tc.attachments, nil)
})
}
files, uploadStarts, uploadStops, deleteFileMessages, err := postToAttachmentsEntries(&tc.post, NewMessageExportStore(mockStore), tc.ignoreDeleted)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
// Need to add the post to the expected starts (to save from copy pasting in the test cases above)
for _, s := range tc.expectedStarts {
s.MessageExport = tc.post
}
// Need to add the post to the expected stops
for _, s := range tc.expectedStops {
s.MessageExport = tc.post
}
// Need to add the post to the expected deleted
var expectedDeleted []PostExport
for _, s := range tc.expectedDeleteFileMessages {
s.MessageExport = tc.post
expectedDeleted = append(expectedDeleted, s)
}
assert.Equal(t, tc.expectedStarts, uploadStarts)
assert.Equal(t, tc.expectedStops, uploadStops)
assert.Equal(t, tc.expectedFileInfos, files)
assert.Equal(t, expectedDeleted, deleteFileMessages)
})
}
}
func TestGetJoinLeavePosts(t *testing.T) {
mockStore := &storetest.Store{}
defer mockStore.AssertExpectations(t)
// This would have been retrieved during CalculateChannelExports
channelMemberHistories := map[string][]*model.ChannelMemberHistoryResult{
"good-request-1": {
{JoinTime: 1, UserId: "test1", UserEmail: "test1", Username: "test1"},
{JoinTime: 2, LeaveTime: model.NewPointer(int64(3)), UserId: "test2", UserEmail: "test2", Username: "test2"},
{JoinTime: 3, UserId: "test3", UserEmail: "test3", Username: "test3"},
},
"good-request-2": {
{JoinTime: 4, UserId: "test4", UserEmail: "test4", Username: "test4"},
{JoinTime: 5, LeaveTime: model.NewPointer(int64(6)), UserId: "test5", UserEmail: "test5", Username: "test5"},
{JoinTime: 6, UserId: "test6", UserEmail: "test6", Username: "test6"},
},
}
var joins []JoinExport
var leaves []LeaveExport
for _, id := range []string{"good-request-1", "good-request-2"} {
newJoins, newLeaves := getJoinsAndLeaves(
1,
7,
channelMemberHistories[id],
nil,
)
joins = append(joins, newJoins...)
leaves = append(leaves, newLeaves...)
}
assert.Len(t, joins, 6)
assert.Equal(t, JoinExport{
UserId: "test1",
Username: "test1",
UserEmail: "test1",
UserType: User,
JoinTime: 1,
LeaveTime: 7,
}, joins[0])
assert.Equal(t, JoinExport{
UserId: "test2",
Username: "test2",
UserEmail: "test2",
UserType: User,
JoinTime: 2,
LeaveTime: 3,
}, joins[1])
assert.Equal(t, JoinExport{
UserId: "test3",
Username: "test3",
UserEmail: "test3",
UserType: User,
JoinTime: 3,
LeaveTime: 7,
}, joins[2])
assert.Equal(t, JoinExport{
UserId: "test4",
Username: "test4",
UserEmail: "test4",
UserType: User,
JoinTime: 4,
LeaveTime: 7,
}, joins[3])
assert.Equal(t, JoinExport{
UserId: "test5",
Username: "test5",
UserEmail: "test5",
UserType: User,
JoinTime: 5,
LeaveTime: 6,
}, joins[4])
assert.Equal(t, JoinExport{
UserId: "test6",
Username: "test6",
UserEmail: "test6",
UserType: User,
JoinTime: 6,
LeaveTime: 7,
}, joins[5])
// remember that getJoinsAndLeaves sorts _for each channel_
assert.Len(t, leaves, 6)
// 1st channel:
assert.Equal(t, LeaveExport{
UserId: "test2",
Username: "test2",
UserEmail: "test2",
UserType: User,
LeaveTime: 3,
}, leaves[0])
assert.Equal(t, LeaveExport{
UserId: "test1",
Username: "test1",
UserEmail: "test1",
UserType: User,
LeaveTime: 7,
ClosedOut: true,
}, leaves[1])
assert.Equal(t, LeaveExport{
UserId: "test3",
Username: "test3",
UserEmail: "test3",
UserType: User,
LeaveTime: 7,
ClosedOut: true,
}, leaves[2])
// 2nd channel:
assert.Equal(t, LeaveExport{
UserId: "test5",
Username: "test5",
UserEmail: "test5",
UserType: User,
LeaveTime: 6,
}, leaves[3])
assert.Equal(t, LeaveExport{
UserId: "test4",
Username: "test4",
UserEmail: "test4",
UserType: User,
LeaveTime: 7,
ClosedOut: true,
}, leaves[4])
assert.Equal(t, LeaveExport{
UserId: "test6",
Username: "test6",
UserEmail: "test6",
UserType: User,
LeaveTime: 7,
ClosedOut: true,
}, leaves[5])
}

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

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var mainHelper *testlib.MainHelper
func TestMain(m *testing.M) {
mainHelper = testlib.NewMainHelper()
defer mainHelper.Close()
api4.SetMainHelper(mainHelper)
mainHelper.Main(m)
}

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

@@ -0,0 +1,609 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"encoding/json"
"fmt"
"path"
"strconv"
"time"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
MissingFileMessage = "File missing for post; cannot copy file to archive"
EstimatedPostCount = 10_000_000
// JobDataBatchStartTime is the posts.updateat value from the previous batch. Posts are selected using
// keyset pagination sorted by (posts.updateat, posts.id).
JobDataBatchStartTime = "batch_start_time"
// JobDataJobStartTime is the start of the job (doesn't change across batches)
JobDataJobStartTime = "job_start_time"
// JobDataBatchStartId is the posts.id value from the previous batch.
JobDataBatchStartId = "batch_start_id"
// JobDataJobEndTime is the point up to which this job is exporting. It is the time the job was started,
// i.e., we export everything from the end of previous batch to the moment this batch started.
JobDataJobEndTime = "job_end_time"
JobDataJobStartId = "job_start_id"
JobDataExportType = "export_type"
JobDataBatchSize = "batch_size"
JobDataChannelBatchSize = "channel_batch_size"
JobDataChannelHistoryBatchSize = "channel_history_batch_size"
JobDataMessagesExported = "messages_exported"
JobDataWarningCount = "warning_count"
JobDataIsDownloadable = "is_downloadable"
JobDataExportDir = "export_dir"
JobDataBatchNumber = "job_batch_number"
JobDataTotalPostsExpected = "total_posts_expected"
)
type PostUpdatedType string
const (
EditedOriginalMsg PostUpdatedType = "EditedOriginalMsg"
EditedNewMsg PostUpdatedType = "EditedNewMsg"
UpdatedNoMsgChange PostUpdatedType = "UpdatedNoMsgChange"
Deleted PostUpdatedType = "Deleted"
FileDeleted PostUpdatedType = "FileDeleted"
)
// JobData keeps the current state of the job.
// When used by a worker, all fields in JobDataExported are exported to the job's job.Data prop bag.
type JobData struct {
JobDataExported
ExportPeriodStartTime int64
// This section is the current state of the export
ChannelMetadata map[string]*MetadataChannel
ChannelMemberHistories map[string][]*model.ChannelMemberHistoryResult
Cursor model.MessageExportCursor
PostsToExport []*model.MessageExport
BatchEndTime int64
BatchPath string
MessageExportMs []int64
ProcessingPostsMs []int64
ProcessingXmlMs []int64
TransferringFilesMs []int64
TransferringZipMs []int64
TotalBatchMs []int64
Finished bool
}
type JobDataExported struct {
ExportType string
ExportDir string
BatchStartTime int64
BatchStartId string
JobStartTime int64
JobEndTime int64
JobStartId string
BatchSize int
ChannelBatchSize int
ChannelHistoryBatchSize int
BatchNumber int
TotalPostsExpected int
MessagesExported int
WarningCount int
IsDownloadable bool
}
func JobDataToStringMap(jd JobData) map[string]string {
ret := make(map[string]string)
ret[JobDataExportType] = jd.ExportType
ret[JobDataExportDir] = jd.ExportDir
ret[JobDataBatchStartTime] = strconv.FormatInt(jd.BatchStartTime, 10)
ret[JobDataBatchStartId] = jd.BatchStartId
ret[JobDataJobStartTime] = strconv.FormatInt(jd.JobStartTime, 10)
ret[JobDataJobEndTime] = strconv.FormatInt(jd.JobEndTime, 10)
ret[JobDataJobStartId] = jd.JobStartId
ret[JobDataBatchSize] = strconv.Itoa(jd.BatchSize)
ret[JobDataChannelBatchSize] = strconv.Itoa(jd.ChannelBatchSize)
ret[JobDataChannelHistoryBatchSize] = strconv.Itoa(jd.ChannelHistoryBatchSize)
ret[JobDataBatchNumber] = strconv.Itoa(jd.BatchNumber)
ret[JobDataTotalPostsExpected] = strconv.Itoa(jd.TotalPostsExpected)
ret[JobDataMessagesExported] = strconv.Itoa(jd.MessagesExported)
ret[JobDataWarningCount] = strconv.Itoa(jd.WarningCount)
ret[JobDataIsDownloadable] = strconv.FormatBool(jd.IsDownloadable)
return ret
}
func StringMapToJobDataWithZeroValues(sm map[string]string) (JobData, error) {
var jd JobData
var err error
jd.ExportType = sm[JobDataExportType]
jd.ExportDir = sm[JobDataExportDir]
batchStartTime, ok := sm[JobDataBatchStartTime]
if !ok {
batchStartTime = "0"
}
if jd.BatchStartTime, err = strconv.ParseInt(batchStartTime, 10, 64); err != nil {
return jd, errors.Wrap(err, "error converting JobDataBatchStartTime")
}
jd.BatchStartId = sm[JobDataBatchStartId]
jobStartTime, ok := sm[JobDataJobStartTime]
if !ok {
jobStartTime = "0"
}
if jd.JobStartTime, err = strconv.ParseInt(jobStartTime, 10, 64); err != nil {
return jd, errors.Wrap(err, "error converting JobDataJobStartTime")
}
jobEndTime, ok := sm[JobDataJobEndTime]
if !ok {
jobEndTime = "0"
}
if jd.JobEndTime, err = strconv.ParseInt(jobEndTime, 10, 64); err != nil {
return jd, errors.Wrap(err, "error converting JobDataJobEndTime")
}
jd.JobStartId = sm[JobDataJobStartId]
jobBatchSize, ok := sm[JobDataBatchSize]
if !ok {
jobBatchSize = "0"
}
if jd.BatchSize, err = strconv.Atoi(jobBatchSize); err != nil {
return jd, errors.Wrap(err, "error converting JobDataBatchSize")
}
channelBatchSize, ok := sm[JobDataChannelBatchSize]
if !ok {
channelBatchSize = "0"
}
if jd.ChannelBatchSize, err = strconv.Atoi(channelBatchSize); err != nil {
return jd, errors.Wrap(err, "error converting JobDataChannelBatchSize")
}
channelHistoryBatchSize, ok := sm[JobDataChannelHistoryBatchSize]
if !ok {
channelHistoryBatchSize = "0"
}
if jd.ChannelHistoryBatchSize, err = strconv.Atoi(channelHistoryBatchSize); err != nil {
return jd, errors.Wrap(err, "error converting JobDataChannelHistoryBatchSize")
}
batchNumber, ok := sm[JobDataBatchNumber]
if !ok {
batchNumber = "0"
}
if jd.BatchNumber, err = strconv.Atoi(batchNumber); err != nil {
return jd, errors.Wrap(err, "error converting JobDataBatchNumber")
}
totalPostsExpected, ok := sm[JobDataTotalPostsExpected]
if !ok {
totalPostsExpected = "0"
}
if jd.TotalPostsExpected, err = strconv.Atoi(totalPostsExpected); err != nil {
return jd, errors.Wrap(err, "error converting JobDataTotalPostsExpected")
}
messagesExported, ok := sm[JobDataMessagesExported]
if !ok {
messagesExported = "0"
}
if jd.MessagesExported, err = strconv.Atoi(messagesExported); err != nil {
return jd, errors.Wrap(err, "error converting JobDataMessagesExported")
}
warningCount, ok := sm[JobDataWarningCount]
if !ok {
warningCount = "0"
}
if jd.WarningCount, err = strconv.Atoi(warningCount); err != nil {
return jd, errors.Wrap(err, "error converting JobDataWarningCount")
}
isDownloadable, ok := sm[JobDataIsDownloadable]
if !ok {
isDownloadable = "0"
}
if jd.IsDownloadable, err = strconv.ParseBool(isDownloadable); err != nil {
return jd, errors.Wrap(err, "error converting JobDataIsDownloadable")
}
return jd, nil
}
type BackendParams struct {
Config *model.Config
Store MessageExportStore
FileAttachmentBackend filestore.FileBackend
ExportBackend filestore.FileBackend
HtmlTemplates *templates.Container
}
type ExportParams struct {
ExportType string
ChannelMetadata map[string]*MetadataChannel
Posts []*model.MessageExport
ChannelMemberHistories map[string][]*model.ChannelMemberHistoryResult
JobStartTime int64
BatchPath string
BatchStartTime int64
BatchEndTime int64
Config *model.Config
Db MessageExportStore
FileAttachmentBackend filestore.FileBackend
ExportBackend filestore.FileBackend
Templates *templates.Container
}
type WriteExportResult struct {
TransferringFilesMs int64
ProcessingXmlMs int64
TransferringZipMs int64
NumWarnings int
}
type RunExportResults struct {
CreatedPosts int
EditedOrigMsgPosts int
EditedNewMsgPosts int
UpdatedPosts int
DeletedPosts int
UploadedFiles int
DeletedFiles int
NumChannels int
Joins int
Leaves int
ProcessingPostsMs int64
WriteExportResult
}
type ChannelMemberJoin struct {
UserId string
IsBot bool
Email string
Username string
Datetime int64
}
type ChannelMemberLeave struct {
UserId string
IsBot bool
Email string
Username string
Datetime int64
}
type ChannelMember struct {
UserId string
IsBot bool
Email string
Username string
}
type MetadataChannel struct {
TeamId *string
TeamName *string
TeamDisplayName *string
ChannelId string
ChannelName string
ChannelDisplayName string
ChannelType model.ChannelType
RoomId string
StartTime int64
EndTime int64
MessagesCount int
AttachmentsCount int
}
type Metadata struct {
Channels map[string]*MetadataChannel
MessagesCount int
AttachmentsCount int
StartTime int64
EndTime int64
}
func (metadata *Metadata) UpdateCounts(channelId string, numMessages int, numAttachments int) error {
_, ok := metadata.Channels[channelId]
if !ok {
return fmt.Errorf("could not find channelId for post in metadata.Channels")
}
metadata.Channels[channelId].AttachmentsCount += numAttachments
metadata.AttachmentsCount += numAttachments
metadata.Channels[channelId].MessagesCount += numMessages
metadata.MessagesCount += numMessages
return nil
}
// GetInitialExportPeriodData calculates and caches the channel memberships, channel metadata, and the TotalPostsExpected.
func GetInitialExportPeriodData(rctx request.CTX, store MessageExportStore, data JobData, reportProgress func(string)) (JobData, error) {
// Counting all posts may fail or timeout when the posts table is large. If this happens, log a warning, but carry
// on with the job anyway. The only issue is that the progress % reporting will be inaccurate.
count, err := store.Post().AnalyticsPostCount(&model.PostCountOptions{ExcludeSystemPosts: true, SincePostID: data.JobStartId, SinceUpdateAt: data.ExportPeriodStartTime, UntilUpdateAt: data.JobEndTime})
if err != nil {
rctx.Logger().Warn("Worker: Failed to fetch total post count for job. An estimated value will be used for progress reporting.", mlog.Err(err))
data.TotalPostsExpected = EstimatedPostCount
} else {
data.TotalPostsExpected = int(count)
}
rctx.Logger().Info("Expecting to export total posts", mlog.Int("total_posts", data.TotalPostsExpected))
// Every time we claim the job, we need to gather the membership data that every batch will use.
// If we're here, then either this is the start of the job, or the job was stopped (e.g., the worker stopped)
// and we've claimed it again. Either way, we need to recalculate channel and member history data.
data.ChannelMetadata, data.ChannelMemberHistories, err = CalculateChannelExports(rctx,
ChannelExportsParams{
Store: store,
ExportPeriodStartTime: data.ExportPeriodStartTime,
ExportPeriodEndTime: data.JobEndTime,
ChannelBatchSize: data.ChannelBatchSize,
ChannelHistoryBatchSize: data.ChannelHistoryBatchSize,
ReportProgressMessage: reportProgress,
})
if err != nil {
return data, err
}
data.Cursor = model.MessageExportCursor{
LastPostUpdateAt: data.BatchStartTime,
LastPostId: data.BatchStartId,
UntilUpdateAt: data.JobEndTime,
}
return data, nil
}
type ChannelExportsParams struct {
Store MessageExportStore
ExportPeriodStartTime int64
ExportPeriodEndTime int64
ChannelBatchSize int
ChannelHistoryBatchSize int
ReportProgressMessage func(message string)
}
// CalculateChannelExports returns the channel info ( map[channelId]*MetadataChannel ) and the channel user
// joins/leaves ( map[channelId][]*model.ChannelMemberHistoryResult ) for any channel that has had activity
// (posts or user join/leaves) between ExportPeriodStartTime and ExportPeriodEndTime.
func CalculateChannelExports(rctx request.CTX, opt ChannelExportsParams) (map[string]*MetadataChannel, map[string][]*model.ChannelMemberHistoryResult, error) {
// Which channels had user activity in the export period?
activeChannelIds, err := opt.Store.ChannelMemberHistory().GetChannelsWithActivityDuring(opt.ExportPeriodStartTime, opt.ExportPeriodEndTime)
if err != nil {
return nil, nil, err
}
if len(activeChannelIds) == 0 {
return nil, nil, nil
}
rctx.Logger().Debug("Started CalculateChannelExports", mlog.Int("export_period_start_time", opt.ExportPeriodStartTime), mlog.Int("export_period_end_time", opt.ExportPeriodEndTime), mlog.Int("num_active_channel_ids", len(activeChannelIds)))
message := rctx.T("ent.message_export.actiance_export.calculate_channel_exports.channel_message", model.StringMap{"NumChannels": strconv.Itoa(len(activeChannelIds))})
opt.ReportProgressMessage(message)
// For each channel, get its metadata.
channelMetadata := make(map[string]*MetadataChannel, len(activeChannelIds))
// Use batches to reduce db load and network waste.
for pos := 0; pos < len(activeChannelIds); pos += opt.ChannelBatchSize {
upTo := min(pos+opt.ChannelBatchSize, len(activeChannelIds))
batch := activeChannelIds[pos:upTo]
channels, err := opt.Store.Channel().GetMany(batch, true)
if err != nil {
return nil, nil, err
}
for _, channel := range channels {
channelMetadata[channel.Id] = &MetadataChannel{
TeamId: model.NewPointer(channel.TeamId),
ChannelId: channel.Id,
ChannelName: channel.Name,
ChannelDisplayName: channel.DisplayName,
ChannelType: channel.Type,
RoomId: fmt.Sprintf("%v - %v", ChannelTypeDisplayName(channel.Type), channel.Id),
StartTime: opt.ExportPeriodStartTime,
EndTime: opt.ExportPeriodEndTime,
}
}
}
historiesByChannelId := make(map[string][]*model.ChannelMemberHistoryResult, len(activeChannelIds))
var batchTimes []int64
// Now that we have metadata, get channelMemberHistories for each channel.
// Use batches to reduce total db load and network waste.
for pos := 0; pos < len(activeChannelIds); pos += opt.ChannelHistoryBatchSize {
// This may take a while, so update the system console UI.
message := rctx.T("ent.message_export.actiance_export.calculate_channel_exports.activity_message", model.StringMap{
"NumChannels": strconv.Itoa(len(activeChannelIds)),
"NumCompleted": strconv.Itoa(pos),
})
opt.ReportProgressMessage(message)
start := time.Now()
upTo := min(pos+opt.ChannelHistoryBatchSize, len(activeChannelIds))
batch := activeChannelIds[pos:upTo]
channelMemberHistories, err := opt.Store.ChannelMemberHistory().GetUsersInChannelDuring(opt.ExportPeriodStartTime, opt.ExportPeriodEndTime, batch)
if err != nil {
return nil, nil, err
}
batchTimes = append(batchTimes, time.Since(start).Milliseconds())
// collect the channelMemberHistories by channelId
for _, entry := range channelMemberHistories {
historiesByChannelId[entry.ChannelId] = append(historiesByChannelId[entry.ChannelId], entry)
}
}
rctx.Logger().Info("GetUsersInChannelDuring batch times", mlog.Array("batch_times", batchTimes))
return channelMetadata, historiesByChannelId, nil
}
// ChannelHasActivity returns true if the channel (represented by the []*model.ChannelMemberHistoryResult slice)
// had user activity between startTime and endTime
func ChannelHasActivity(cmhs []*model.ChannelMemberHistoryResult, startTime int64, endTime int64) bool {
for _, cmh := range cmhs {
if (cmh.JoinTime >= startTime && cmh.JoinTime <= endTime) ||
(cmh.LeaveTime != nil && *cmh.LeaveTime >= startTime && *cmh.LeaveTime <= endTime) {
return true
}
}
return false
}
func GetJoinsAndLeavesForChannel(startTime int64, endTime int64, channelMembersHistory []*model.ChannelMemberHistoryResult,
postAuthors map[string]ChannelMember) ([]ChannelMemberJoin, []ChannelMemberLeave) {
var joins []ChannelMemberJoin
var leaves []ChannelMemberLeave
alreadyJoined := make(map[string]bool)
for _, cmh := range channelMembersHistory {
if cmh.UserDeleteAt > 0 && cmh.UserDeleteAt < startTime {
continue
}
if cmh.JoinTime > endTime {
continue
}
if cmh.LeaveTime != nil && *cmh.LeaveTime < startTime {
continue
}
if cmh.JoinTime <= endTime {
joins = append(joins, ChannelMemberJoin{
UserId: cmh.UserId,
IsBot: cmh.IsBot,
Email: cmh.UserEmail,
Username: cmh.Username,
Datetime: cmh.JoinTime,
})
alreadyJoined[cmh.UserId] = true
}
if cmh.LeaveTime != nil && *cmh.LeaveTime <= endTime {
leaves = append(leaves, ChannelMemberLeave{
UserId: cmh.UserId,
IsBot: cmh.IsBot,
Email: cmh.UserEmail,
Username: cmh.Username,
Datetime: *cmh.LeaveTime,
})
}
}
for _, member := range postAuthors {
if alreadyJoined[member.UserId] {
continue
}
joins = append(joins, ChannelMemberJoin{
UserId: member.UserId,
IsBot: member.IsBot,
Email: member.Email,
Username: member.Username,
Datetime: startTime,
})
}
return joins, leaves
}
// GetPostAttachments if the post included any files, we need to add special elements to the export.
func GetPostAttachments(db MessageExportStore, post *model.MessageExport) ([]*model.FileInfo, error) {
if len(post.PostFileIds) == 0 {
return []*model.FileInfo{}, nil
}
attachments, err := db.FileInfo().GetForPost(*post.PostId, true, true, false)
if err != nil {
return nil, fmt.Errorf("failed to get file info for a post: %w", err)
}
return attachments, nil
}
func ChannelTypeDisplayName(channelType model.ChannelType) string {
return map[model.ChannelType]string{
model.ChannelTypeOpen: "public",
model.ChannelTypePrivate: "private",
model.ChannelTypeDirect: "direct",
model.ChannelTypeGroup: "group",
}[channelType]
}
func GetBatchPath(exportDir string, prevPostUpdateAt int64, lastPostUpdateAt int64, batchNumber int) string {
if exportDir == "" {
exportDir = path.Join(model.ComplianceExportPath, time.Now().Format(model.ComplianceExportDirectoryFormat))
}
return path.Join(exportDir,
fmt.Sprintf("batch%03d-%d-%d.zip", batchNumber, prevPostUpdateAt, lastPostUpdateAt))
}
// GetExportBackend returns the file backend where the export will be created.
func GetExportBackend(rctx request.CTX, config *model.Config) (filestore.FileBackend, error) {
insecure := config.ServiceSettings.EnableInsecureOutgoingConnections
skipVerify := insecure != nil && *insecure
if config.FileSettings.DedicatedExportStore != nil && *config.FileSettings.DedicatedExportStore {
rctx.Logger().Debug("Worker: using dedicated export filestore", mlog.String("driver_name", *config.FileSettings.ExportDriverName))
backend, errFileBack := filestore.NewExportFileBackend(filestore.NewExportFileBackendSettingsFromConfig(&config.FileSettings, true, skipVerify))
if errFileBack != nil {
return nil, errFileBack
}
return backend, nil
}
backend, err := filestore.NewFileBackend(filestore.NewFileBackendSettingsFromConfig(&config.FileSettings, true, skipVerify))
if err != nil {
return nil, err
}
return backend, nil
}
// GetFileAttachmentBackend returns the file backend where file attachments are
// located for messages that will be exported. This may be the same backend
// where the export will be created.
func GetFileAttachmentBackend(rctx request.CTX, config *model.Config) (filestore.FileBackend, error) {
insecure := config.ServiceSettings.EnableInsecureOutgoingConnections
backend, err := filestore.NewFileBackend(filestore.NewFileBackendSettingsFromConfig(&config.FileSettings, true, insecure != nil && *insecure))
if err != nil {
return nil, err
}
return backend, nil
}
func IsDeletedMsg(post *model.MessageExport) bool {
if model.SafeDereference(post.PostDeleteAt) > 0 && post.PostProps != nil {
props := map[string]any{}
err := json.Unmarshal([]byte(*post.PostProps), &props)
if err != nil {
return false
}
if _, ok := props[model.PostPropsDeleteBy]; ok {
return true
}
}
return false
}

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

@@ -0,0 +1,286 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetJoinsAndLeavesForChannel(t *testing.T) {
channel := MetadataChannel{
StartTime: 100,
EndTime: 200,
ChannelId: "good-request-1",
TeamId: model.NewPointer("test"),
TeamName: model.NewPointer("test"),
TeamDisplayName: model.NewPointer("test"),
ChannelName: "test",
ChannelDisplayName: "test",
ChannelType: "O",
}
tt := []struct {
name string
channel MetadataChannel
membersHistory []*model.ChannelMemberHistoryResult
usersInPosts map[string]ChannelMember
expectedJoins int
expectedLeaves int
}{
{
name: "no-joins-no-leaves",
channel: channel,
membersHistory: nil,
usersInPosts: nil,
expectedJoins: 0,
expectedLeaves: 0,
},
{
name: "joins-and-leaves-outside-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 1, LeaveTime: model.NewPointer(int64(10)), UserId: "test", UserEmail: "test", Username: "test"},
{JoinTime: 250, LeaveTime: model.NewPointer(int64(260)), UserId: "test", UserEmail: "test", Username: "test"},
{JoinTime: 300, UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 0,
expectedLeaves: 0,
},
{
name: "join-and-leave-during-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 100, LeaveTime: model.NewPointer(int64(150)), UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 1,
},
{
name: "join-during-and-leave-after-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 150, LeaveTime: model.NewPointer(int64(300)), UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 0,
},
{
name: "join-before-and-leave-during-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 99, LeaveTime: model.NewPointer(int64(150)), UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 1,
},
{
name: "join-before-and-leave-after-the-range",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 99, LeaveTime: model.NewPointer(int64(350)), UserId: "test", UserEmail: "test", Username: "test"},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 0,
},
{
name: "implicit-joins",
channel: channel,
membersHistory: nil,
usersInPosts: map[string]ChannelMember{
"test1": {UserId: "test1", Email: "test1", Username: "test1"},
"test2": {UserId: "test2", Email: "test2", Username: "test2"},
},
expectedJoins: 2,
expectedLeaves: 0,
},
{
name: "implicit-joins-with-explicit-joins",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: model.NewPointer(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1"},
{JoinTime: 130, LeaveTime: model.NewPointer(int64(150)), UserId: "test3", UserEmail: "test3", Username: "test3"},
},
usersInPosts: map[string]ChannelMember{
"test1": {UserId: "test1", Email: "test1", Username: "test1"},
"test2": {UserId: "test2", Email: "test2", Username: "test2"},
},
expectedJoins: 3,
expectedLeaves: 2,
},
{
name: "join-leave-and-join-again",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: model.NewPointer(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1"},
{JoinTime: 160, LeaveTime: model.NewPointer(int64(180)), UserId: "test1", UserEmail: "test1", Username: "test1"},
},
usersInPosts: nil,
expectedJoins: 2,
expectedLeaves: 2,
},
{
name: "deactivated-members-dont-show",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: model.NewPointer(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 50},
{JoinTime: 160, LeaveTime: model.NewPointer(int64(180)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 50},
},
usersInPosts: nil,
expectedJoins: 0,
expectedLeaves: 0,
},
{
name: "deactivated-members-show-if-deleted-after-latest-export",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: model.NewPointer(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 150},
{JoinTime: 160, LeaveTime: model.NewPointer(int64(180)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 150},
},
usersInPosts: nil,
expectedJoins: 2,
expectedLeaves: 2,
},
{
name: "deactivated-members-show-and-dont-show",
channel: channel,
membersHistory: []*model.ChannelMemberHistoryResult{
{JoinTime: 130, LeaveTime: model.NewPointer(int64(150)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 50},
{JoinTime: 160, LeaveTime: model.NewPointer(int64(180)), UserId: "test1", UserEmail: "test1", Username: "test1", UserDeleteAt: 150},
},
usersInPosts: nil,
expectedJoins: 1,
expectedLeaves: 1,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
joins, leaves := GetJoinsAndLeavesForChannel(tc.channel.StartTime, tc.channel.EndTime, tc.membersHistory, tc.usersInPosts)
assert.Len(t, joins, tc.expectedJoins)
assert.Len(t, leaves, tc.expectedLeaves)
})
}
}
func Test_GetBatchPath(t *testing.T) {
tests := []struct {
name string
exportDir string
prevPostUpdateAt int64
lastPostUpdateAt int64
batchNumber int
want string
}{
{
name: "all args given",
exportDir: "/export/test_dir",
prevPostUpdateAt: 123,
lastPostUpdateAt: 456,
batchNumber: 21,
want: "/export/test_dir/batch021-123-456.zip",
},
{
name: "exportDir blank",
exportDir: "",
prevPostUpdateAt: 12345,
lastPostUpdateAt: 456789,
batchNumber: 921,
want: model.ComplianceExportPath + "/" + time.Now().Format(model.ComplianceExportDirectoryFormat) + "/batch921-12345-456789.zip",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, GetBatchPath(tt.exportDir, tt.prevPostUpdateAt, tt.lastPostUpdateAt, tt.batchNumber), "GetBatchPath(%v, %v, %v, %v)", tt.exportDir, tt.prevPostUpdateAt, tt.lastPostUpdateAt, tt.batchNumber)
})
}
}
func TestJobDataToStringMap_and_StringMapToJobData(t *testing.T) {
jd := JobData{
JobDataExported: JobDataExported{
ExportType: "cli_message_export",
ExportDir: "/here/there/34234-123",
BatchStartTime: 45,
BatchStartId: "34arsitenaorsten",
JobStartTime: 99,
JobEndTime: 1234,
JobStartId: "99abcdef34",
BatchSize: 2000,
ChannelBatchSize: 30000,
ChannelHistoryBatchSize: 30,
BatchNumber: 4,
TotalPostsExpected: 999999,
MessagesExported: 343499,
WarningCount: 39,
},
ExportPeriodStartTime: 123456, // not exported
BatchEndTime: 999999999, // not exported
BatchPath: "/another/path/123-123", // not exported
}
strMap := JobDataToStringMap(jd)
expected := make(map[string]string)
expected[JobDataExportType] = "cli_message_export"
expected[JobDataExportDir] = "/here/there/34234-123"
expected[JobDataBatchStartTime] = "45"
expected[JobDataBatchStartId] = "34arsitenaorsten"
expected[JobDataJobStartTime] = "99"
expected[JobDataJobEndTime] = "1234"
expected[JobDataJobStartId] = "99abcdef34"
expected[JobDataBatchSize] = "2000"
expected[JobDataChannelBatchSize] = "30000"
expected[JobDataChannelHistoryBatchSize] = "30"
expected[JobDataBatchNumber] = "4"
expected[JobDataTotalPostsExpected] = "999999"
expected[JobDataMessagesExported] = "343499"
expected[JobDataWarningCount] = "39"
expected[JobDataIsDownloadable] = "false"
for k, v := range expected {
val, ok := strMap[k]
assert.True(t, ok)
assert.Equal(t, v, val)
}
// not exported:
for _, k := range []string{"export_period_start_time", "batch_end_time", "batch_path"} {
_, ok := strMap[k]
assert.False(t, ok)
}
// zero the fields that weren't exported:
jd.ExportPeriodStartTime = 0
jd.BatchEndTime = 0
jd.BatchPath = ""
// now convert back
jd2, err := StringMapToJobDataWithZeroValues(strMap)
assert.NoError(t, err)
assert.Equal(t, jd, jd2)
// and test bad conversion (just a couple):
badStrMap := map[string]string{JobDataJobStartTime: "56aaa"}
_, err = StringMapToJobDataWithZeroValues(badStrMap)
assert.Error(t, err)
badStrMap = map[string]string{JobDataJobEndTime: "blah blah"}
_, err = StringMapToJobDataWithZeroValues(badStrMap)
assert.Error(t, err)
// test that zero values are used when not present
emptyStrMap := make(map[string]string)
emptyJd, err := StringMapToJobDataWithZeroValues(emptyStrMap)
assert.NoError(t, err)
assert.Equal(t, JobData{}, emptyJd)
}

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

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.enterprise for license information.
package shared
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type MessageExportStore interface {
Post() store.PostStore
ChannelMemberHistory() store.ChannelMemberHistoryStore
Channel() store.ChannelStore
Compliance() store.ComplianceStore
FileInfo() MEFileInfoStore
}
type MEFileInfoStore interface {
GetForPost(postID string, readFromMaster, includeDeleted, allowFromCache bool) ([]*model.FileInfo, error)
}
type messageExportStore struct {
store.Store
}
// NewMessageExportStore returns a wrapped store.Store. Why? Because the CLI tool needs to "override" the GetForPost
// method in FileStore. Non-CLI code only needs to use the store.Store's methods. This can be removed when we deprecate
// the CLI tool. Tracked at MM-61139.
func NewMessageExportStore(s store.Store) messageExportStore {
return messageExportStore{s}
}
func (ss messageExportStore) FileInfo() MEFileInfoStore {
return ss.Store.FileInfo()
}

101
server/enterprise/message_export/testdata/actianceE2E1Batch1ch2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,101 @@
<Conversation Perspective="the Channel Two">
<RoomID>private - channel_two_name - %s</RoomID>
<StartTimeUTC>%d</StartTimeUTC>
<ParticipantEntered>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user1@email</CorporateEmailID>
</ParticipantEntered>
<ParticipantEntered>
<LoginName>user2@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user2@email</CorporateEmailID>
</ParticipantEntered>
<ParticipantEntered>
<LoginName>user3@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user3@email</CorporateEmailID>
</ParticipantEntered>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 0</Content>
</Message>
<FileTransferStarted>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_0.txt</FileName>
</FileTransferStarted>
<FileTransferEnded>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_0.txt</FileName>
<Status>Completed</Status>
</FileTransferEnded>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 1</Content>
</Message>
<FileTransferStarted>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_1.txt</FileName>
</FileTransferStarted>
<FileTransferEnded>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_1.txt</FileName>
<Status>Completed</Status>
</FileTransferEnded>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 2</Content>
</Message>
<FileTransferStarted>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_2.txt</FileName>
</FileTransferStarted>
<FileTransferEnded>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_2.txt</FileName>
<Status>Completed</Status>
</FileTransferEnded>
<ParticipantLeft>
<LoginName>user2@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user2@email</CorporateEmailID>
</ParticipantLeft>
<ParticipantLeft>
<LoginName>user3@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user3@email</CorporateEmailID>
</ParticipantLeft>
<ParticipantLeft>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user1@email</CorporateEmailID>
</ParticipantLeft>
<EndTimeUTC>%d</EndTimeUTC>
</Conversation>

17
server/enterprise/message_export/testdata/actianceE2E1Batch1ch3.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,17 @@
<Conversation Perspective="the Channel Three">
<RoomID>public - channel_three_name - %s</RoomID>
<StartTimeUTC>%d</StartTimeUTC>
<ParticipantEntered>
<LoginName>user7@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user7@email</CorporateEmailID>
</ParticipantEntered>
<ParticipantLeft>
<LoginName>user7@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user7@email</CorporateEmailID>
</ParticipantLeft>
<EndTimeUTC>%d</EndTimeUTC>
</Conversation>

92
server/enterprise/message_export/testdata/actianceE2E1Batch2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<FileDump xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Conversation Perspective="the Channel Two">
<RoomID>private - channel_two_name - %s</RoomID>
<StartTimeUTC>%d</StartTimeUTC>
<ParticipantEntered>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user1@email</CorporateEmailID>
</ParticipantEntered>
<ParticipantEntered>
<LoginName>user4@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user4@email</CorporateEmailID>
</ParticipantEntered>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 3</Content>
</Message>
<FileTransferStarted>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_3.txt</FileName>
</FileTransferStarted>
<FileTransferEnded>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_3.txt</FileName>
<Status>Completed</Status>
</FileTransferEnded>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 4</Content>
</Message>
<FileTransferStarted>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_4.txt</FileName>
</FileTransferStarted>
<FileTransferEnded>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_4.txt</FileName>
<Status>Completed</Status>
</FileTransferEnded>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 5</Content>
</Message>
<FileTransferStarted>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_5.txt</FileName>
</FileTransferStarted>
<FileTransferEnded>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_5.txt</FileName>
<Status>Completed</Status>
</FileTransferEnded>
<ParticipantLeft>
<LoginName>user4@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user4@email</CorporateEmailID>
</ParticipantLeft>
<ParticipantLeft>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user1@email</CorporateEmailID>
</ParticipantLeft>
<EndTimeUTC>%d</EndTimeUTC>
</Conversation>
</FileDump>

101
server/enterprise/message_export/testdata/actianceE2E1Batch3ch2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,101 @@
<Conversation Perspective="the Channel Two">
<RoomID>private - channel_two_name - %s</RoomID>
<StartTimeUTC>%d</StartTimeUTC>
<ParticipantEntered>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user1@email</CorporateEmailID>
</ParticipantEntered>
<ParticipantEntered>
<LoginName>user5@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user5@email</CorporateEmailID>
</ParticipantEntered>
<ParticipantEntered>
<LoginName>user6@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user6@email</CorporateEmailID>
</ParticipantEntered>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 6</Content>
</Message>
<FileTransferStarted>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_6.txt</FileName>
</FileTransferStarted>
<FileTransferEnded>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_6.txt</FileName>
<Status>Completed</Status>
</FileTransferEnded>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 7</Content>
</Message>
<FileTransferStarted>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_7.txt</FileName>
</FileTransferStarted>
<FileTransferEnded>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_7.txt</FileName>
<Status>Completed</Status>
</FileTransferEnded>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 8</Content>
</Message>
<FileTransferStarted>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_8.txt</FileName>
</FileTransferStarted>
<FileTransferEnded>
<LoginName>user1@email</LoginName>
<DateTimeUTC>%d</DateTimeUTC>
<UserFileName></UserFileName>
<FileName>path/to/attachments/file_8.txt</FileName>
<Status>Completed</Status>
</FileTransferEnded>
<ParticipantLeft>
<LoginName>user5@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user5@email</CorporateEmailID>
</ParticipantLeft>
<ParticipantLeft>
<LoginName>user6@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user6@email</CorporateEmailID>
</ParticipantLeft>
<ParticipantLeft>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user1@email</CorporateEmailID>
</ParticipantLeft>
<EndTimeUTC>%d</EndTimeUTC>
</Conversation>

17
server/enterprise/message_export/testdata/actianceE2E1Batch3ch4.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,17 @@
<Conversation Perspective="the Channel Four">
<RoomID>public - channel_four_name - %s</RoomID>
<StartTimeUTC>%d</StartTimeUTC>
<ParticipantEntered>
<LoginName>user8@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user8@email</CorporateEmailID>
</ParticipantEntered>
<ParticipantLeft>
<LoginName>user8@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user8@email</CorporateEmailID>
</ParticipantLeft>
<EndTimeUTC>%d</EndTimeUTC>
</Conversation>

46
server/enterprise/message_export/testdata/actianceE2E2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<FileDump xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Conversation Perspective="the Channel Two">
<RoomID>private - channel_two_name - %s</RoomID>
<StartTimeUTC>%d</StartTimeUTC>
<ParticipantEntered>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user1@email</CorporateEmailID>
</ParticipantEntered>
<ParticipantEntered>
<LoginName>user2@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user2@email</CorporateEmailID>
</ParticipantEntered>
<Message>
<MessageId>%s</MessageId>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 1</Content>
</Message>
<Message>
<MessageId>%s</MessageId>
<LoginName>user2@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<Content>message 2</Content>
</Message>
<ParticipantLeft>
<LoginName>user1@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user1@email</CorporateEmailID>
</ParticipantLeft>
<ParticipantLeft>
<LoginName>user2@email</LoginName>
<UserType>user</UserType>
<DateTimeUTC>%d</DateTimeUTC>
<CorporateEmailID>user2@email</CorporateEmailID>
</ParticipantLeft>
<EndTimeUTC>%d</EndTimeUTC>
</Conversation>
</FileDump>

3
server/enterprise/message_export/testdata/actianceXMLHeader.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<FileDump xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
%s%s</FileDump>

14
server/enterprise/message_export/testdata/csvE2E1Batch1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,14 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[16]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[17]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[7]s,user2@email,user2,,,,User user2 (user2@email) joined the channel,enter,user,
%[18]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[7]s,user2@email,user2,,,,User user2 (user2@email) left the channel,leave,user,
%[19]d,%[19]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[10]s,,,message 0,message,user,
%[19]d,%[19]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[10]s,,,(files/%[10]s/%[13]s-file_0.txt),attachment,user,
%[20]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[8]s,user3@email,user3,,,,User user3 (user3@email) joined the channel,enter,user,
%[21]d,%[21]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[11]s,,,message 1,message,user,
%[21]d,%[21]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[11]s,,,(files/%[11]s/%[14]s-file_1.txt),attachment,user,
%[22]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[8]s,user3@email,user3,,,,User user3 (user3@email) left the channel,leave,user,
%[23]d,0,,%[1]s,,,%[5]s,channel_three_name,the Channel Three,public,%[9]s,user7@email,user7,,,,User user7 (user7@email) was already in the channel,previously-joined,user,
%[24]d,0,,%[1]s,,,%[5]s,channel_three_name,the Channel Three,public,%[9]s,user7@email,user7,,,,User user7 (user7@email) left the channel,leave,user,
%[25]d,%[25]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[12]s,,,message 2,message,user,
%[25]d,%[25]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[12]s,,,(files/%[12]s/%[15]s-file_2.txt),attachment,user,

10
server/enterprise/message_export/testdata/csvE2E1Batch2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,10 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[13]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[14]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user4@email,user4,,,,User user4 (user4@email) joined the channel,enter,user,
%[15]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user4@email,user4,,,,User user4 (user4@email) left the channel,leave,user,
%[16]d,%[16]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,message 3,message,user,
%[16]d,%[16]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,(files/%[7]s/%[10]s-file_3.txt),attachment,user,
%[17]d,%[17]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[8]s,,,message 4,message,user,
%[17]d,%[17]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[8]s,,,(files/%[8]s/%[11]s-file_4.txt),attachment,user,
%[18]d,%[18]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,message 5,message,user,
%[18]d,%[18]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,(files/%[9]s/%[12]s-file_5.txt),attachment,user,

13
server/enterprise/message_export/testdata/csvE2E1Batch3.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,13 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[16]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[17]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[7]s,user5@email,user5,,,,User user5 (user5@email) joined the channel,enter,user,
%[18]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[7]s,user5@email,user5,,,,User user5 (user5@email) left the channel,leave,user,
%[19]d,%[19]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[10]s,,,message 6,message,user,
%[19]d,%[19]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[10]s,,,(files/%[10]s/%[13]s-file_6.txt),attachment,user,
%[20]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[11]s,,,message 7,message,user,
%[20]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[11]s,,,(files/%[11]s/%[14]s-file_7.txt),attachment,user,
%[21]d,%[21]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[12]s,,,message 8,message,user,
%[21]d,%[21]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user1@email,user1,%[12]s,,,(files/%[12]s/%[15]s-file_8.txt),attachment,user,
%[22]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[8]s,user6@email,user6,,,,User user6 (user6@email) joined the channel,enter,user,
%[23]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[8]s,user6@email,user6,,,,User user6 (user6@email) left the channel,leave,user,
%[24]d,0,,%[1]s,,,%[5]s,channel_four_name,the Channel Four,public,%[9]s,user8@email,user8,,,,User user8 (user8@email) joined the channel,enter,user,

5
server/enterprise/message_export/testdata/csvE2E2Batch1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,5 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[9]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[10]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user2@email,user2,,,,User user2 (user2@email) was already in the channel,previously-joined,user,
%[11]d,%[11]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,message 1,message,user,
%[12]d,%[12]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[6]s,user2@email,user2,%[8]s,,,message 2,message,user,

13
server/enterprise/message_export/testdata/csvE2E3Batch1Perm1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,13 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[14]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[15]d,%[15]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[6]s,,,message 0,message,user,
%[16]d,%[17]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,message 1,message,user,
%[16]d,%[17]d,Deleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,delete message 1,message,user,
%[18]d,%[25]d,UpdatedNoMsgChange,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[8]s,,,message 2,message,user,
%[19]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,message 3,message,user,
%[19]d,%[20]d,Deleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,delete message 3,message,user,
%[19]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,(files/%[9]s/%[13]s-file_3.txt),attachment,user,
%[19]d,%[20]d,FileDeleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,(files/%[9]s/%[13]s-file_3.txt),deleted attachment,user,
%[21]d,%[22]d,EditedOriginalMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[10]s,%[11]s,,message 4,message,user,
%[21]d,%[22]d,EditedNewMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[11]s,,,edited message 4,message,user,
%[23]d,%[26]d,EditedOriginalMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[12]s,%[24]s,,message 6,message,user,

13
server/enterprise/message_export/testdata/csvE2E3Batch1Perm2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,13 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[14]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[15]d,%[15]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[6]s,,,message 0,message,user,
%[16]d,%[17]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,message 1,message,user,
%[16]d,%[17]d,Deleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,delete message 1,message,user,
%[18]d,%[25]d,UpdatedNoMsgChange,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[8]s,,,message 2,message,user,
%[19]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,message 3,message,user,
%[19]d,%[20]d,Deleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,delete message 3,message,user,
%[19]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,(files/%[9]s/%[13]s-file_3.txt),attachment,user,
%[19]d,%[20]d,FileDeleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,(files/%[9]s/%[13]s-file_3.txt),deleted attachment,user,
%[21]d,%[22]d,EditedNewMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[11]s,,,edited message 4,message,user,
%[21]d,%[22]d,EditedOriginalMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[10]s,%[11]s,,message 4,message,user,
%[23]d,%[26]d,EditedOriginalMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[12]s,%[24]s,,message 6,message,user,

13
server/enterprise/message_export/testdata/csvE2E3Batch1Perm3.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,13 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[14]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[15]d,%[15]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[6]s,,,message 0,message,user,
%[16]d,%[17]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,message 1,message,user,
%[16]d,%[17]d,Deleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,delete message 1,message,user,
%[18]d,%[24]d,UpdatedNoMsgChange,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[8]s,,,message 2,message,user,
%[19]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,message 3,message,user,
%[19]d,%[20]d,Deleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,delete message 3,message,user,
%[19]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,(files/%[9]s/%[13]s-file_3.txt),attachment,user,
%[19]d,%[20]d,FileDeleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,(files/%[9]s/%[13]s-file_3.txt),deleted attachment,user,
%[21]d,%[22]d,EditedOriginalMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[10]s,%[11]s,,message 4,message,user,
%[21]d,%[22]d,EditedNewMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[11]s,,,edited message 4,message,user,
%[25]d,%[26]d,EditedNewMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[12]s,,,edited message 6,message,user,

13
server/enterprise/message_export/testdata/csvE2E3Batch1Perm4.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,13 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[14]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[15]d,%[15]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[6]s,,,message 0,message,user,
%[16]d,%[17]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,message 1,message,user,
%[16]d,%[17]d,Deleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,delete message 1,message,user,
%[18]d,%[24]d,UpdatedNoMsgChange,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[8]s,,,message 2,message,user,
%[19]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,message 3,message,user,
%[19]d,%[20]d,Deleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,delete message 3,message,user,
%[19]d,%[20]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,(files/%[9]s/%[13]s-file_3.txt),attachment,user,
%[19]d,%[20]d,FileDeleted,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,(files/%[9]s/%[13]s-file_3.txt),deleted attachment,user,
%[21]d,%[22]d,EditedNewMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[11]s,,,edited message 4,message,user,
%[21]d,%[22]d,EditedOriginalMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[10]s,%[11]s,,message 4,message,user,
%[25]d,%[26]d,EditedNewMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[12]s,,,edited message 6,message,user,

3
server/enterprise/message_export/testdata/csvE2E3Batch2Perm1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,3 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[9]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[8]d,%[10]d,EditedOriginalMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[6]s,%[7]s,,message 6,message,user,

3
server/enterprise/message_export/testdata/csvE2E3Batch2Perm2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,3 @@
Post Creation Time,Post Update Time,Post Update Type,Team Id,Team Name,Team Display Name,Channel Id,Channel Name,Channel Display Name,Channel Type,User Id,User Email,Username,Post Id,Edited By Post Id,Replied to Post Id,Post Message,Post Type,User Type,Previews Post Id
%[8]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[7]d,%[9]d,EditedNewMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[6]s,,,edited message 6,message,user,

6
server/enterprise/message_export/testdata/csvE2E4Batch1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,6 @@
%[11]d,0,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,,,,User user1 (user1@email) was already in the channel,previously-joined,user,
%[12]d,%[14]d,EditedOriginalMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[6]s,%[7]s,,message 0,message,user,
%[12]d,%[14]d,EditedNewMsg,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[7]s,,,edited message 0,message,user,
%[13]d,%[14]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[8]s,,,message 2,message,user,
%[13]d,%[14]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[9]s,,,message 3,message,user,
%[13]d,%[14]d,,%[1]s,%[2]s,%[3]s,%[4]s,channel_two_name,the Channel Two,private,%[5]s,user1@email,user1,%[10]s,,,message 4,message,user,

2
server/enterprise/message_export/testdata/csvE2E5Job1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,2 @@
1732245676086,czoko3u8oidrtcgt58qi3afmrc,faketeambfc64t,dn_qtkwchcc7jng5mo87qw5gzxojo,zao11dtduinninqwtbjn3c6jzw,fakechannelsucatskrmt,dn_pyo5j7yqui865qt69ikoi6sgcr,public,741xw3i71frkmks64of8myh4gc,tfxgyrwfbbymzqz3c74sysbqao@localhost,fakeuser6b4riuzx5b,pf4y8d9s77domftewwfzk4fn5o,,,message 0,message,user,
1732245676088,czoko3u8oidrtcgt58qi3afmrc,faketeambfc64t,dn_qtkwchcc7jng5mo87qw5gzxojo,zao11dtduinninqwtbjn3c6jzw,fakechannelsucatskrmt,dn_pyo5j7yqui865qt69ikoi6sgcr,public,741xw3i71frkmks64of8myh4gc,tfxgyrwfbbymzqz3c74sysbqao@localhost,fakeuser6b4riuzx5b,pf4y8d9s77domftewwfzk4fn5o,,,delete message 0,message,user,

137
server/enterprise/message_export/testdata/grE2E1Batch1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,137 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[13]s</li>
<li><span class=3D"bold">TeamName:</span>%[14]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[15]s</li>
<li><span class=3D"bold">ChannelId:</span>%[22]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[16]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">3</td>
</tr>
<tr>
<td class=3D"userid">%[17]s</td>
<td class=3D"username">@user2</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user2@email</td>
<td class=3D"joined">%[5]s</td>
<td class=3D"left">%[6]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">0</td>
</tr>
<tr>
<td class=3D"userid">%[18]s</td>
<td class=3D"username">@user3</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user3@email</td>
<td class=3D"joined">%[7]s</td>
<td class=3D"left">%[8]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">0</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[9]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 0</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[9]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[20]s</span>
<span class=3D"sent_time">%[10]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 1</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[20]s</span>
<span class=3D"sent_time">%[10]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[11]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 2</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[11]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
</ul>
</div>
<p>Exported on %[12]s</p>

47
server/enterprise/message_export/testdata/grE2E1Batch1Ch3.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,47 @@
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[1]s</li>
<li><span class=3D"bold">TeamName:</span></li>
<li><span class=3D"bold">TeamDisplayName:</span></li>
<li><span class=3D"bold">ChannelId:</span>%[2]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_three_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Three</li>
<li><span class=3D"bold">Started:</span>%[3]s</li>
<li><span class=3D"bold">Ended:</span>%[4]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[5]s</td>
<td class=3D"username">@user7</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user7@email</td>
<td class=3D"joined">%[6]s</td>
<td class=3D"left">%[7]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">0</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
</ul>
</div>
<p>Exported on %[8]s</p>

29
server/enterprise/message_export/testdata/grE2E1Batch1Summary.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,29 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[14]s
* TeamName: %[15]s
* TeamDisplayName: %[16]s
* ChannelId: %[13]s
* ChannelName: channel_two_name
* ChannelDisplayName: %[1]s
* Started: %[2]s
* Ended: %[3]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[17]s @user1 user user1@email %[4]s %[5]s 0 seconds 3 %[18]s @user2 user user2@email %[6]s %[7]s 0 seconds 0 %[19]s @user3 user user3@email %[8]s %[9]s 0 seconds 0
--------
Messages
--------
* %[20]s %[10]s @user1 %[17]s @user1 user (user1@email) message 0
* %[20]s %[10]s @user1 %[17]s @user1 user (user1@email) Uploaded file
* %[21]s %[11]s @user1 %[17]s @user1 user (user1@email) message 1
* %[21]s %[11]s @user1 %[17]s @user1 user (user1@email) Uploaded file
* %[22]s %[12]s @user1 %[17]s @user1 user (user1@email) message 2
* %[22]s %[12]s @user1 %[17]s @user1 user (user1@email) Uploaded file

22
server/enterprise/message_export/testdata/grE2E1Batch1SummaryCh3.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,22 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[1]s
* TeamName:
* TeamDisplayName:
* ChannelId: %[4]s
* ChannelName: channel_three_name
* ChannelDisplayName: the Channel Three
* Started: %[5]s
* Ended: %[6]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[7]s @user7 user user7@email %[8]s %[9]s 0 seconds 0
--------
Messages
--------

126
server/enterprise/message_export/testdata/grE2E1Batch2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,126 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[11]s</li>
<li><span class=3D"bold">TeamName:</span>%[12]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[13]s</li>
<li><span class=3D"bold">ChannelId:</span>%[20]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[14]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">3</td>
</tr>
<tr>
<td class=3D"userid">%[16]s</td>
<td class=3D"username">@user4</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user4@email</td>
<td class=3D"joined">%[5]s</td>
<td class=3D"left">%[6]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">0</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[17]s</span>
<span class=3D"sent_time">%[7]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[14]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 3</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[17]s</span>
<span class=3D"sent_time">%[7]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[14]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[18]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[14]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 4</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[18]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[14]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[9]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[14]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 5</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[9]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[14]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
</ul>
</div>
<p>Exported on %[10]s</p>

29
server/enterprise/message_export/testdata/grE2E1Batch2Summary.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,29 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[11]s
* TeamName: %[12]s
* TeamDisplayName: %[13]s
* ChannelId: %[10]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[14]s @user1 user user1@email %[3]s %[4]s 0 seconds 3 %[15]s @user4 user user4@email %[5]s %[6]s 0 seconds 0
--------
Messages
--------
* %[16]s %[7]s @user1 %[14]s @user1 user (user1@email) message 3
* %[16]s %[7]s @user1 %[14]s @user1 user (user1@email) Uploaded file
* %[17]s %[8]s @user1 %[14]s @user1 user (user1@email) message 4
* %[17]s %[8]s @user1 %[14]s @user1 user (user1@email) Uploaded file
* %[18]s %[9]s @user1 %[14]s @user1 user (user1@email) message 5
* %[18]s %[9]s @user1 %[14]s @user1 user (user1@email) Uploaded file

137
server/enterprise/message_export/testdata/grE2E1Batch3.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,137 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[13]s</li>
<li><span class=3D"bold">TeamName:</span>%[14]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[15]s</li>
<li><span class=3D"bold">ChannelId:</span>%[22]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[16]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">3</td>
</tr>
<tr>
<td class=3D"userid">%[17]s</td>
<td class=3D"username">@user5</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user5@email</td>
<td class=3D"joined">%[5]s</td>
<td class=3D"left">%[6]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">0</td>
</tr>
<tr>
<td class=3D"userid">%[18]s</td>
<td class=3D"username">@user6</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user6@email</td>
<td class=3D"joined">%[7]s</td>
<td class=3D"left">%[8]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">0</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[9]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 6</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[9]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[20]s</span>
<span class=3D"sent_time">%[10]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 7</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[20]s</span>
<span class=3D"sent_time">%[10]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[11]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 8</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[11]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[16]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
</ul>
</div>
<p>Exported on %[12]s</p>

47
server/enterprise/message_export/testdata/grE2E1Batch3Ch4.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,47 @@
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[1]s</li>
<li><span class=3D"bold">TeamName:</span></li>
<li><span class=3D"bold">TeamDisplayName:</span></li>
<li><span class=3D"bold">ChannelId:</span>%[2]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_four_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Four</li>
<li><span class=3D"bold">Started:</span>%[3]s</li>
<li><span class=3D"bold">Ended:</span>%[4]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[5]s</td>
<td class=3D"username">@user8</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user8@email</td>
<td class=3D"joined">%[6]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">0</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
</ul>
</div>
<p>Exported on %[8]s</p>

29
server/enterprise/message_export/testdata/grE2E1Batch3Summary.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,29 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[13]s
* TeamName: %[14]s
* TeamDisplayName: %[15]s
* ChannelId: %[12]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[16]s @user1 user user1@email %[3]s %[4]s 0 seconds 3 %[22]s @user5 user user5@email %[5]s %[6]s 0 seconds 0 %[23]s @user6 user user6@email %[7]s %[8]s 0 seconds 0
--------
Messages
--------
* %[19]s %[9]s @user1 %[16]s @user1 user (user1@email) message 6
* %[19]s %[9]s @user1 %[16]s @user1 user (user1@email) Uploaded file
* %[20]s %[10]s @user1 %[16]s @user1 user (user1@email) message 7
* %[20]s %[10]s @user1 %[16]s @user1 user (user1@email) Uploaded file
* %[21]s %[11]s @user1 %[16]s @user1 user (user1@email) message 8
* %[21]s %[11]s @user1 %[16]s @user1 user (user1@email) Uploaded file

22
server/enterprise/message_export/testdata/grE2E1Batch3SummaryCh4.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,22 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[1]s
* TeamName:
* TeamDisplayName:
* ChannelId: %[4]s
* ChannelName: channel_four_name
* ChannelDisplayName: the Channel Four
* Started: %[5]s
* Ended: %[6]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[7]s @user8 user user8@email %[8]s %[6]s 0 seconds 0
--------
Messages
--------

82
server/enterprise/message_export/testdata/grE2E2Batch1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,82 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[10]s</li>
<li><span class=3D"bold">TeamName:</span>%[11]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[12]s</li>
<li><span class=3D"bold">ChannelId:</span>%[13]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[14]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">1</td>
</tr>
<tr>
<td class=3D"userid">%[15]s</td>
<td class=3D"username">@user2</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user2@email</td>
<td class=3D"joined">%[5]s</td>
<td class=3D"left">%[6]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">1</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[16]s</span>
<span class=3D"sent_time">%[7]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[14]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 1</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[17]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user2</span>
<span class=3D"userid">%[15]s</span>
<span class=3D"postusername">@user2</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user2@email)</span>
<span class=3D"message">message 2</span>
</li>
</ul>
</div>
<p>Exported on %[9]s</p>

25
server/enterprise/message_export/testdata/grE2E2Batch1Summary.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,25 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[9]s
* TeamName: %[10]s
* TeamDisplayName: %[11]s
* ChannelId: %[12]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[13]s @user1 user user1@email %[3]s %[4]s 0 seconds 1 %[14]s @user2 user user2@email %[5]s %[6]s 0 seconds 1
--------
Messages
--------
* %[15]s %[7]s @user1 %[13]s @user1 user (user1@email) message 1
* %[16]s %[8]s @user2 %[14]s @user2 user (user2@email) message 2

191
server/enterprise/message_export/testdata/grE2E3Batch1Perm1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,191 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[13]s</li>
<li><span class=3D"bold">TeamName:</span>%[14]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[15]s</li>
<li><span class=3D"bold">ChannelId:</span>%[16]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[17]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">9</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[18]s</span>
<span class=3D"sent_time">%[5]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 0</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[6]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 1</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[6]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">delete message 1</span>
<span class=3D"update_type">Deleted</span>
<span class=3D"update_time">%[26]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[20]s</span>
<span class=3D"sent_time">%[7]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 2</span>
<span class=3D"update_type">UpdatedNoMsgChange</span>
<span class=3D"update_time">%[27]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 3</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">delete message 3</span>
<span class=3D"update_type">Deleted</span>
<span class=3D"update_time">%[9]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Deleted file </span>
<span class=3D"update_type">FileDeleted</span>
<span class=3D"update_time">%[9]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[22]s</span>
<span class=3D"sent_time">%[10]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 4</span>
<span class=3D"update_type">EditedOriginalMsg</span>
<span class=3D"update_time">%[28]s</span>
<span class=3D"edited_new_msg_id">%[23]s</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[23]s</span>
<span class=3D"sent_time">%[11]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">edited message 4</span>
<span class=3D"update_type">EditedNewMsg</span>
<span class=3D"update_time">%[28]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[24]s</span>
<span class=3D"sent_time">%[12]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 6</span>
<span class=3D"update_type">EditedOriginalMsg</span>
<span class=3D"update_time">%[29]s</span>
<span class=3D"edited_new_msg_id">%[25]s</span>
</li>
</ul>
</div>
<p>Exported on %[1]s</p>

191
server/enterprise/message_export/testdata/grE2E3Batch1Perm2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,191 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[13]s</li>
<li><span class=3D"bold">TeamName:</span>%[14]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[15]s</li>
<li><span class=3D"bold">ChannelId:</span>%[16]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[17]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">9</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[18]s</span>
<span class=3D"sent_time">%[5]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 0</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[6]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 1</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[6]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">delete message 1</span>
<span class=3D"update_type">Deleted</span>
<span class=3D"update_time">%[26]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[20]s</span>
<span class=3D"sent_time">%[7]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 2</span>
<span class=3D"update_type">UpdatedNoMsgChange</span>
<span class=3D"update_time">%[27]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 3</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">delete message 3</span>
<span class=3D"update_type">Deleted</span>
<span class=3D"update_time">%[9]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Deleted file </span>
<span class=3D"update_type">FileDeleted</span>
<span class=3D"update_time">%[9]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[23]s</span>
<span class=3D"sent_time">%[11]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">edited message 4</span>
<span class=3D"update_type">EditedNewMsg</span>
<span class=3D"update_time">%[28]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[22]s</span>
<span class=3D"sent_time">%[10]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 4</span>
<span class=3D"update_type">EditedOriginalMsg</span>
<span class=3D"update_time">%[28]s</span>
<span class=3D"edited_new_msg_id">%[23]s</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[24]s</span>
<span class=3D"sent_time">%[12]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 6</span>
<span class=3D"update_type">EditedOriginalMsg</span>
<span class=3D"update_time">%[29]s</span>
<span class=3D"edited_new_msg_id">%[25]s</span>
</li>
</ul>
</div>
<p>Exported on %[1]s</p>

191
server/enterprise/message_export/testdata/grE2E3Batch1Perm3.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,191 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[13]s</li>
<li><span class=3D"bold">TeamName:</span>%[14]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[15]s</li>
<li><span class=3D"bold">ChannelId:</span>%[16]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[17]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">9</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[18]s</span>
<span class=3D"sent_time">%[5]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 0</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[6]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 1</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[6]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">delete message 1</span>
<span class=3D"update_type">Deleted</span>
<span class=3D"update_time">%[26]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[20]s</span>
<span class=3D"sent_time">%[7]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 2</span>
<span class=3D"update_type">UpdatedNoMsgChange</span>
<span class=3D"update_time">%[27]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 3</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">delete message 3</span>
<span class=3D"update_type">Deleted</span>
<span class=3D"update_time">%[9]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Deleted file </span>
<span class=3D"update_type">FileDeleted</span>
<span class=3D"update_time">%[9]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[22]s</span>
<span class=3D"sent_time">%[10]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 4</span>
<span class=3D"update_type">EditedOriginalMsg</span>
<span class=3D"update_time">%[28]s</span>
<span class=3D"edited_new_msg_id">%[23]s</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[23]s</span>
<span class=3D"sent_time">%[11]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">edited message 4</span>
<span class=3D"update_type">EditedNewMsg</span>
<span class=3D"update_time">%[28]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[25]s</span>
<span class=3D"sent_time">%[12]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">edited message 6</span>
<span class=3D"update_type">EditedNewMsg</span>
<span class=3D"update_time">%[29]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
</ul>
</div>
<p>Exported on %[1]s</p>

191
server/enterprise/message_export/testdata/grE2E3Batch1Perm4.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,191 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[13]s</li>
<li><span class=3D"bold">TeamName:</span>%[14]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[15]s</li>
<li><span class=3D"bold">ChannelId:</span>%[16]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[17]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">9</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[18]s</span>
<span class=3D"sent_time">%[5]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 0</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[6]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 1</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[19]s</span>
<span class=3D"sent_time">%[6]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">delete message 1</span>
<span class=3D"update_type">Deleted</span>
<span class=3D"update_time">%[26]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[20]s</span>
<span class=3D"sent_time">%[7]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 2</span>
<span class=3D"update_type">UpdatedNoMsgChange</span>
<span class=3D"update_time">%[27]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 3</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Uploaded file </span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">delete message 3</span>
<span class=3D"update_type">Deleted</span>
<span class=3D"update_time">%[9]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[21]s</span>
<span class=3D"sent_time">%[8]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">Deleted file </span>
<span class=3D"update_type">FileDeleted</span>
<span class=3D"update_time">%[9]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[23]s</span>
<span class=3D"sent_time">%[11]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">edited message 4</span>
<span class=3D"update_type">EditedNewMsg</span>
<span class=3D"update_time">%[28]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[22]s</span>
<span class=3D"sent_time">%[10]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 4</span>
<span class=3D"update_type">EditedOriginalMsg</span>
<span class=3D"update_time">%[28]s</span>
<span class=3D"edited_new_msg_id">%[23]s</span>
</li>
<li class=3D"message">
<span class=3D"post_id">%[25]s</span>
<span class=3D"sent_time">%[12]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[17]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">edited message 6</span>
<span class=3D"update_type">EditedNewMsg</span>
<span class=3D"update_time">%[29]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
</ul>
</div>
<p>Exported on %[1]s</p>

34
server/enterprise/message_export/testdata/grE2E3Batch1SummaryPerm1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,34 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[13]s
* TeamName: %[14]s
* TeamDisplayName: %[15]s
* ChannelId: %[16]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[17]s @user1 user user1@email %[3]s %[4]s 0 seconds 9
--------
Messages
--------
* %[18]s %[5]s @user1 %[17]s @user1 user (user1@email) message 0
* %[19]s %[6]s @user1 %[17]s @user1 user (user1@email) message 1
* %[19]s %[6]s @user1 %[17]s @user1 user (user1@email) delete message 1 Deleted %[26]s
* %[20]s %[7]s @user1 %[17]s @user1 user (user1@email) message 2 UpdatedNoMsgChange %[27]s
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) message 3
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) Uploaded file
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) delete message 3 Deleted %[9]s
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) Deleted file FileDeleted %[9]s
* %[22]s %[10]s @user1 %[17]s @user1 user (user1@email) message 4 EditedOriginalMsg %[28]s %[23]s
* %[23]s %[11]s @user1 %[17]s @user1 user (user1@email) edited message 4 EditedNewMsg %[28]s
* %[24]s %[12]s @user1 %[17]s @user1 user (user1@email) message 6 EditedOriginalMsg %[29]s %[25]s

34
server/enterprise/message_export/testdata/grE2E3Batch1SummaryPerm2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,34 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[13]s
* TeamName: %[14]s
* TeamDisplayName: %[15]s
* ChannelId: %[16]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[17]s @user1 user user1@email %[3]s %[4]s 0 seconds 9
--------
Messages
--------
* %[18]s %[5]s @user1 %[17]s @user1 user (user1@email) message 0
* %[19]s %[6]s @user1 %[17]s @user1 user (user1@email) message 1
* %[19]s %[6]s @user1 %[17]s @user1 user (user1@email) delete message 1 Deleted %[26]s
* %[20]s %[7]s @user1 %[17]s @user1 user (user1@email) message 2 UpdatedNoMsgChange %[27]s
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) message 3
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) Uploaded file
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) delete message 3 Deleted %[9]s
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) Deleted file FileDeleted %[9]s
* %[23]s %[11]s @user1 %[17]s @user1 user (user1@email) edited message 4 EditedNewMsg %[28]s
* %[22]s %[10]s @user1 %[17]s @user1 user (user1@email) message 4 EditedOriginalMsg %[28]s %[23]s
* %[24]s %[12]s @user1 %[17]s @user1 user (user1@email) message 6 EditedOriginalMsg %[29]s %[25]s

34
server/enterprise/message_export/testdata/grE2E3Batch1SummaryPerm3.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,34 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[13]s
* TeamName: %[14]s
* TeamDisplayName: %[15]s
* ChannelId: %[16]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[17]s @user1 user user1@email %[3]s %[4]s 0 seconds 9
--------
Messages
--------
* %[18]s %[5]s @user1 %[17]s @user1 user (user1@email) message 0
* %[19]s %[6]s @user1 %[17]s @user1 user (user1@email) message 1
* %[19]s %[6]s @user1 %[17]s @user1 user (user1@email) delete message 1 Deleted %[26]s
* %[20]s %[7]s @user1 %[17]s @user1 user (user1@email) message 2 UpdatedNoMsgChange %[27]s
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) message 3
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) Uploaded file
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) delete message 3 Deleted %[9]s
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) Deleted file FileDeleted %[9]s
* %[22]s %[10]s @user1 %[17]s @user1 user (user1@email) message 4 EditedOriginalMsg %[28]s %[23]s
* %[23]s %[11]s @user1 %[17]s @user1 user (user1@email) edited message 4 EditedNewMsg %[28]s
* %[25]s %[12]s @user1 %[17]s @user1 user (user1@email) edited message 6 EditedNewMsg %[29]s

34
server/enterprise/message_export/testdata/grE2E3Batch1SummaryPerm4.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,34 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[13]s
* TeamName: %[14]s
* TeamDisplayName: %[15]s
* ChannelId: %[16]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[17]s @user1 user user1@email %[3]s %[4]s 0 seconds 9
--------
Messages
--------
* %[18]s %[5]s @user1 %[17]s @user1 user (user1@email) message 0
* %[19]s %[6]s @user1 %[17]s @user1 user (user1@email) message 1
* %[19]s %[6]s @user1 %[17]s @user1 user (user1@email) delete message 1 Deleted %[26]s
* %[20]s %[7]s @user1 %[17]s @user1 user (user1@email) message 2 UpdatedNoMsgChange %[27]s
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) message 3
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) Uploaded file
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) delete message 3 Deleted %[9]s
* %[21]s %[8]s @user1 %[17]s @user1 user (user1@email) Deleted file FileDeleted %[9]s
* %[23]s %[11]s @user1 %[17]s @user1 user (user1@email) edited message 4 EditedNewMsg %[28]s
* %[22]s %[10]s @user1 %[17]s @user1 user (user1@email) message 4 EditedOriginalMsg %[28]s %[23]s
* %[25]s %[12]s @user1 %[17]s @user1 user (user1@email) edited message 6 EditedNewMsg %[29]s

63
server/enterprise/message_export/testdata/grE2E3Batch2Perm1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,63 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[6]s</li>
<li><span class=3D"bold">TeamName:</span>%[7]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[8]s</li>
<li><span class=3D"bold">ChannelId:</span>%[9]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[10]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">1</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[14]s</span>
<span class=3D"sent_time">%[5]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[10]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">edited message 6</span>
<span class=3D"update_type">EditedNewMsg</span>
<span class=3D"update_time">%[12]s</span>
<span class=3D"edited_new_msg_id"></span>
</li>
</ul>
</div>
<p>Exported on %[15]s</p>

63
server/enterprise/message_export/testdata/grE2E3Batch2Perm2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,63 @@
<h1>Mattermost Compliance Export</h1>
<h2>Conversation Summary</h2>
<div class=3D"summary-list">
<ul>
<li><span class=3D"bold">TeamId:</span>%[6]s</li>
<li><span class=3D"bold">TeamName:</span>%[7]s</li>
<li><span class=3D"bold">TeamDisplayName:</span>%[8]s</li>
<li><span class=3D"bold">ChannelId:</span>%[9]s</li>
<li><span class=3D"bold">ChannelName:</span>channel_two_name</li>
<li><span class=3D"bold">ChannelDisplayName:</span>the Channel Two</li>
<li><span class=3D"bold">Started:</span>%[1]s</li>
<li><span class=3D"bold">Ended:</span>%[2]s</li>
<li><span class=3D"bold">Duration:</span>0 seconds</li>
</ul>
</div>
<table class=3D"participants">
<tr>
<th class=3D"userid">UserId<br></th>
<th class=3D"username">Username<br></th>
<th class=3D"usertype">UserType<br></th>
<th class=3D"email">Email</th>
<th class=3D"joined">Joined</th>
<th class=3D"left">Left</th>
<th class=3D"duration">Duration</th>
<th class=3D"messages">Messages</th>
</tr>
=20
<tr>
<td class=3D"userid">%[10]s</td>
<td class=3D"username">@user1</td>
<td class=3D"usertype">user</td>
<td class=3D"email">user1@email</td>
<td class=3D"joined">%[3]s</td>
<td class=3D"left">%[4]s</td>
<td class=3D"duration">0 seconds</td>
<td class=3D"messages">1</td>
</tr>
</table>
<h2>Messages</h2>
<div class=3D"message-list">
<ul>
=20
<li class=3D"message">
<span class=3D"post_id">%[13]s</span>
<span class=3D"sent_time">%[5]s</span>
<span class=3D"username">@user1</span>
<span class=3D"userid">%[10]s</span>
<span class=3D"postusername">@user1</span>
<span class=3D"usertype">user</span>
<span class=3D"email">(user1@email)</span>
<span class=3D"message">message 6</span>
<span class=3D"update_type">EditedOriginalMsg</span>
<span class=3D"update_time">%[12]s</span>
<span class=3D"edited_new_msg_id">%[14]s</span>
</li>
</ul>
</div>
<p>Exported on %[15]s</p>

24
server/enterprise/message_export/testdata/grE2E3Batch2SummaryPerm1.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,24 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[6]s
* TeamName: %[7]s
* TeamDisplayName: %[8]s
* ChannelId: %[9]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[10]s @user1 user user1@email %[3]s %[4]s 0 seconds 1
--------
Messages
--------
* %[14]s %[5]s @user1 %[10]s @user1 user (user1@email) edited message 6 EditedNewMsg %[12]s

24
server/enterprise/message_export/testdata/grE2E3Batch2SummaryPerm2.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,24 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[6]s
* TeamName: %[7]s
* TeamDisplayName: %[8]s
* ChannelId: %[9]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[10]s @user1 user user1@email %[3]s %[4]s 0 seconds 1
--------
Messages
--------
* %[13]s %[5]s @user1 %[10]s @user1 user (user1@email) message 6 EditedOriginalMsg %[12]s %[14]s

18
server/enterprise/message_export/testdata/grE2E4Summary.tmpl поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,18 @@
--------------------
Conversation Summary
--------------------
* TeamId: %[5]s
* TeamName: %[6]s
* TeamDisplayName: %[7]s
* ChannelId: %[8]s
* ChannelName: channel_two_name
* ChannelDisplayName: the Channel Two
* Started: %[1]s
* Ended: %[2]s
* Duration: 0 seconds
UserId
Username
UserType
Email Joined Left Duration Messages %[9]s @user1 user user1@email %[3]s %[4]s 0 seconds 5

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

@@ -4,13 +4,10 @@
package message_export
import (
"archive/zip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"strconv"
"sync"
@@ -21,28 +18,14 @@ import (
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/shared"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
)
const (
JobDataBatchStartTimestamp = "batch_start_timestamp" // message export uses keyset pagination sorted by (posts.updateat, posts.id). batch_start_timestamp is the posts.updateat value from the previous batch.
JobDataBatchStartId = "batch_start_id" // message export uses keyset pagination sorted by (posts.updateat, posts.id). batch_start_id is the posts.id value from the previous batch.
const TimeBetweenBatchesMs = 100
JobDataStartTimestamp = "start_timestamp"
JobDataStartId = "start_id"
JobDataExportType = "export_type"
JOB_DATA_BatchSize = "batch_size"
JobDataMessagesExported = "messages_exported"
JobDataWarningCount = "warning_count"
JobDataIsDownloadable = "is_downloadable"
JobDirectories = "job_directories"
TimeBetweenBatches = 100
estimatedPostCount = 10_000_000
)
const exportPath = "export"
// testEndOfBatchCb is only used for testing
var testEndOfBatchCb func(worker *MessageExportWorker)
type MessageExportWorker struct {
name string
@@ -96,106 +79,70 @@ func (dr *MessageExportJobInterfaceImpl) MakeWorker() model.Worker {
}
}
func (worker *MessageExportWorker) IsEnabled(cfg *model.Config) bool {
return worker.license() != nil && *worker.license().Features.MessageExport && *cfg.MessageExportSettings.EnableExport
func (w *MessageExportWorker) IsEnabled(cfg *model.Config) bool {
return w.license() != nil && *w.license().Features.MessageExport && *cfg.MessageExportSettings.EnableExport
}
func (worker *MessageExportWorker) Run() {
worker.stateMut.Lock()
func (w *MessageExportWorker) Run() {
w.stateMut.Lock()
// We have to re-assign the stop channel again, because
// it might happen that the job was restarted due to a config change.
if worker.stopped {
worker.stopped = false
worker.stopCh = make(chan struct{})
worker.context, worker.cancel = context.WithCancel(context.Background())
if w.stopped {
w.stopped = false
w.stopCh = make(chan struct{})
w.context, w.cancel = context.WithCancel(context.Background())
} else {
worker.stateMut.Unlock()
w.stateMut.Unlock()
return
}
// Run is called from a separate goroutine and doesn't return.
// So we cannot Unlock in a defer clause.
worker.stateMut.Unlock()
w.stateMut.Unlock()
worker.logger.Debug("Worker Started")
w.logger.Debug("Worker Started")
defer func() {
worker.logger.Debug("Worker finished")
worker.stoppedCh <- struct{}{}
w.logger.Debug("Worker finished")
w.stoppedCh <- struct{}{}
}()
for {
select {
case <-worker.stopCh:
worker.logger.Debug("Worker: Received stop signal")
case <-w.stopCh:
w.logger.Debug("Worker: Received stop signal")
return
case job := <-worker.jobs:
worker.DoJob(&job)
case job := <-w.jobs:
w.DoJob(&job)
}
}
}
func (worker *MessageExportWorker) Stop() {
worker.stateMut.Lock()
defer worker.stateMut.Unlock()
func (w *MessageExportWorker) Stop() {
w.stateMut.Lock()
defer w.stateMut.Unlock()
// Set to close, and if already closed before, then return.
if worker.stopped {
if w.stopped {
return
}
worker.stopped = true
w.stopped = true
worker.logger.Debug("Worker: Stopping")
worker.cancel()
close(worker.stopCh)
<-worker.stoppedCh
w.logger.Debug("Worker: Stopping")
w.cancel()
close(w.stopCh)
<-w.stoppedCh
}
func (worker *MessageExportWorker) JobChannel() chan<- model.Job {
return worker.jobs
func (w *MessageExportWorker) JobChannel() chan<- model.Job {
return w.jobs
}
// getExportBackend returns the file backend where the export will be created.
func (worker *MessageExportWorker) getExportBackend(rctx request.CTX) (filestore.FileBackend, *model.AppError) {
config := worker.jobServer.Config()
insecure := config.ServiceSettings.EnableInsecureOutgoingConnections
if config.FileSettings.DedicatedExportStore != nil && *config.FileSettings.DedicatedExportStore {
rctx.Logger().Debug("Worker: using dedicated export filestore", mlog.String("driver_name", *config.FileSettings.ExportDriverName))
backend, errFileBack := filestore.NewExportFileBackend(filestore.NewExportFileBackendSettingsFromConfig(&config.FileSettings, true, insecure != nil && *insecure))
if errFileBack != nil {
return nil, model.NewAppError("getFileBackend", "api.file.no_driver.app_error", nil, "", http.StatusInternalServerError).Wrap(errFileBack)
}
return backend, nil
}
backend, err := filestore.NewFileBackend(filestore.NewFileBackendSettingsFromConfig(&config.FileSettings, true, insecure != nil && *insecure))
if err != nil {
return nil, model.NewAppError("getFileBackend", "api.file.no_driver.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return backend, nil
}
// getFileAttachmentBackend returns the file backend where file attachments are
// located for messages that will be exported. This may be the same backend
// where the export will be created.
func (worker *MessageExportWorker) getFileAttachmentBackend(rctx request.CTX) (filestore.FileBackend, *model.AppError) {
config := worker.jobServer.Config()
insecure := config.ServiceSettings.EnableInsecureOutgoingConnections
backend, err := filestore.NewFileBackend(filestore.NewFileBackendSettingsFromConfig(&config.FileSettings, true, insecure != nil && *insecure))
if err != nil {
return nil, model.NewAppError("getFileBackend", "api.file.no_driver.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return backend, nil
}
func (worker *MessageExportWorker) DoJob(job *model.Job) {
logger := worker.logger.With(jobs.JobLoggerFields(job)...)
func (w *MessageExportWorker) DoJob(job *model.Job) {
logger := w.logger.With(jobs.JobLoggerFields(job)...)
logger.Debug("Worker: Received a new candidate job.")
defer worker.jobServer.HandleJobPanic(logger, job)
defer w.jobServer.HandleJobPanic(logger, job)
claimed, appErr := worker.jobServer.ClaimJob(job)
claimed, appErr := w.jobServer.ClaimJob(job)
if appErr != nil {
logger.Info("Worker: Error occurred while trying to claim job", mlog.Err(appErr))
return
@@ -205,299 +152,171 @@ func (worker *MessageExportWorker) DoJob(job *model.Job) {
return
}
var cancelContext request.CTX = request.EmptyContext(worker.logger)
var cancelContext request.CTX = request.EmptyContext(w.logger)
cancelCtx, cancelCancelWatcher := context.WithCancel(context.Background())
cancelWatcherChan := make(chan struct{}, 1)
cancelContext = cancelContext.WithContext(cancelCtx)
go worker.jobServer.CancellationWatcher(cancelContext, job.Id, cancelWatcherChan)
go w.jobServer.CancellationWatcher(cancelContext, job.Id, cancelWatcherChan)
defer cancelCancelWatcher()
// if job data is missing, we'll do our best to recover
worker.initJobData(logger, job)
// the initJobData call above populates the create_at timestamp of the first post that we should export
// incase of job resumption or new job
batchStartTime, err := strconv.ParseInt(job.Data[JobDataBatchStartTimestamp], 10, 64)
w.initJobData(logger, job, time.Now())
data, err := extractJobData(logger, job.Data)
if err != nil {
worker.setJobError(logger, job, model.NewAppError("Job.DoJob", model.NoTranslation, nil, "", http.StatusBadRequest).Wrap((err)))
return
}
batchStartId := job.Data[JobDataBatchStartId]
jobStartTime, err := strconv.ParseInt(job.Data[JobDataStartTimestamp], 10, 64)
if err != nil {
worker.setJobError(logger, job, model.NewAppError("Job.DoJob", model.NoTranslation, nil, "", http.StatusBadRequest).Wrap((err)))
return
}
jobStartId := job.Data[JobDataStartId]
batchSize, err := strconv.Atoi(job.Data[JOB_DATA_BatchSize])
if err != nil {
worker.setJobError(logger, job, model.NewAppError("Job.DoJob", model.NoTranslation, nil, "", http.StatusBadRequest).Wrap((err)))
// Error in conversion. Not much we can do about that. But it shouldn't happen, unless someone edited the db.
w.setJobError(logger, job, model.NewAppError("Job.DoJob", "ent.message_export.job_data_conversion.app_error", nil, "", http.StatusBadRequest).Wrap(err))
return
}
totalPostsExported, err := strconv.ParseInt(job.Data[JobDataMessagesExported], 10, 64)
rctx := request.EmptyContext(logger).WithContext(w.context)
reportProgress := func(message string) {
logger.Debug(message)
// Don't fail because we couldn't update progress.
w.setJobProgressMessage(0, message, rctx.Logger(), job)
}
jobParams := shared.BackendParams{
Config: w.jobServer.Config(),
Store: shared.NewMessageExportStore(w.jobServer.Store),
HtmlTemplates: w.htmlTemplateWatcher,
}
jobParams.FileAttachmentBackend, err = shared.GetFileAttachmentBackend(rctx, w.jobServer.Config())
if err != nil {
worker.setJobError(logger, job, model.NewAppError("Job.DoJob", model.NoTranslation, nil, "", http.StatusBadRequest).Wrap((err)))
w.setJobError(logger, job, model.NewAppError("GetFileAttachmentBackend", "api.file.no_driver.app_error", nil, "", http.StatusInternalServerError).Wrap(err))
return
}
jobParams.ExportBackend, err = shared.GetExportBackend(rctx, w.jobServer.Config())
if err != nil {
w.setJobError(logger, job, model.NewAppError("GetExportBackend", "api.file.no_driver.app_error", nil, "", http.StatusInternalServerError).Wrap(err))
return
}
var directories []string
err = json.Unmarshal([]byte(job.Data[JobDirectories]), &directories)
data, err = shared.GetInitialExportPeriodData(rctx, jobParams.Store, data, reportProgress)
if err != nil {
worker.setJobError(logger, job, model.NewAppError("Job.DoJob", model.NoTranslation, nil, "", http.StatusBadRequest).Wrap((err)))
w.setJobError(logger, job, model.NewAppError("DoJob", "ent.message_export.calculate_channel_exports.app_error", nil, "", http.StatusInternalServerError).Wrap(err))
return
}
job.Data[shared.JobDataTotalPostsExpected] = strconv.Itoa(data.TotalPostsExpected)
// Counting all posts may fail or timeout when the posts table is large. If this happens, log a warning, but carry
// on with the job anyway. The only issue is that the progress % reporting will be inaccurate.
var totalPosts int64
if count, err := worker.jobServer.Store.Post().AnalyticsPostCount(&model.PostCountOptions{ExcludeSystemPosts: true, SincePostID: jobStartId, SinceUpdateAt: jobStartTime}); err != nil {
logger.Warn("Worker: Failed to fetch total post count for job. An estimated value will be used for progress reporting.", mlog.Err(err))
totalPosts = estimatedPostCount
} else {
totalPosts = count
}
var totalWarningCount int64
cursor := model.MessageExportCursor{LastPostUpdateAt: batchStartTime, LastPostId: batchStartId}
for {
select {
case <-cancelWatcherChan:
logger.Debug("Worker: Job has been canceled via CancellationWatcher")
worker.setJobCanceled(logger, job)
w.setJobCanceled(logger, job)
return
case <-worker.stopCh:
case <-w.stopCh:
logger.Debug("Worker: Job has been canceled via Worker Stop. Setting the job back to pending")
worker.SetJobPending(logger, job)
w.SetJobPending(logger, job)
return
case <-time.After(TimeBetweenBatches * time.Millisecond):
logger.Debug("Starting batch export", mlog.Int("last_post_update_at", cursor.LastPostUpdateAt))
rctx := request.EmptyContext(logger).WithContext(worker.context)
prevPostUpdateAt := cursor.LastPostUpdateAt
case <-time.After(TimeBetweenBatchesMs * time.Millisecond):
logger.Debug("Starting batch export", mlog.Int("last_post_update_at", data.Cursor.LastPostUpdateAt))
var postsExported []*model.MessageExport
var nErr error
postsExported, cursor, nErr = worker.jobServer.Store.Compliance().MessageExport(rctx, cursor, batchSize)
if nErr != nil {
_, data, err = RunBatch(rctx, data, jobParams)
if err != nil {
// We ignore error if the job was explicitly cancelled
// and let it
if worker.context.Err() == context.Canceled {
if errors.Is(w.context.Err(), context.Canceled) {
logger.Debug("Worker: Job has been canceled via worker's context. Setting the job back to pending")
worker.SetJobPending(logger, job)
w.SetJobPending(logger, job)
} else {
worker.setJobError(logger, job, model.NewAppError("DoJob", "ent.message_export.run_export.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr))
}
return
}
logger.Debug("Found posts to export", mlog.Int("number_of_posts", len(postsExported)))
totalPostsExported += int64(len(postsExported))
job.Data[JobDataMessagesExported] = strconv.FormatInt(totalPostsExported, 10)
job.Data[JobDataBatchStartTimestamp] = strconv.FormatInt(cursor.LastPostUpdateAt, 10)
job.Data[JobDataBatchStartId] = cursor.LastPostId
if len(postsExported) == 0 {
job.Data[JobDataWarningCount] = strconv.FormatInt(totalWarningCount, 10)
// we've exported everything up to the current time
logger.Debug("FormatExport complete")
// Create downloadable zip file of all batches.
if job.Data[JobDataExportType] != model.ComplianceExportTypeGlobalrelay {
exportBackend, err := worker.getExportBackend(rctx)
if err != nil {
worker.setJobError(logger, job, err)
return
}
zipErr := createZipFile(rctx, exportBackend, job.Id, directories)
if zipErr != nil {
logger.Error("Error creating zip file for export", mlog.Err(zipErr))
job.Data[JobDataIsDownloadable] = "false"
} else {
job.Data[JobDataIsDownloadable] = "true"
}
}
if totalWarningCount > 0 {
worker.setJobWarning(logger, job)
} else {
worker.setJobSuccess(logger, job)
w.setJobError(logger, job, model.NewAppError("DoJob", "ent.message_export.run_export.app_error", nil, "", http.StatusInternalServerError).Wrap(err))
}
return
}
exportBackend, err := worker.getExportBackend(rctx)
if err != nil {
worker.setJobError(logger, job, err)
setJobDataEndOfBatch(job, data)
if data.Finished {
w.finishExport(rctx, logger, job, data.WarningCount)
return
}
fileAttachmentBackend, err := worker.getFileAttachmentBackend(rctx)
if err != nil {
worker.setJobError(logger, job, err)
// also saves job.Data
if err := w.setJobProgress(logger, job, getJobProgress(data.MessagesExported, data.TotalPostsExpected)); err != nil {
// TODO: MM-59093 handle job errors (robust, recoverable)
return
}
batchDirectory := getOutputDirectoryPath(prevPostUpdateAt, cursor.LastPostUpdateAt)
warningCount, err := runExportByType(
rctx,
job.Data[JobDataExportType],
postsExported,
batchDirectory,
worker.jobServer.Store,
exportBackend,
fileAttachmentBackend,
worker.htmlTemplateWatcher,
worker.jobServer.Config(),
)
if err != nil {
worker.setJobError(logger, job, err)
return
}
totalWarningCount += warningCount
directories = append(directories, batchDirectory)
directoriesBytes, e := json.Marshal(directories)
if e != nil {
worker.setJobError(logger, job, model.NewAppError("Job.DoJob", model.NoTranslation, nil, "", http.StatusInternalServerError).Wrap((e)))
return
}
job.Data[JobDirectories] = string(directoriesBytes)
// also saves the last post create time
if err := worker.jobServer.SetJobProgress(job, getJobProgress(totalPostsExported, totalPosts)); err != nil {
worker.setJobError(logger, job, err)
return
// testEndOfBatchCb is only used by tests.
if testEndOfBatchCb != nil {
testEndOfBatchCb(w)
}
}
}
}
func createZipFile(rctx request.CTX, fileBackend filestore.FileBackend, jobId string, directories []string) error {
zipFileName := jobId + ".zip"
func (w *MessageExportWorker) finishExport(rctx request.CTX, logger *mlog.Logger, job *model.Job, totalWarningCount int) {
job.Data[shared.JobDataWarningCount] = strconv.Itoa(totalWarningCount)
// we've exported everything up to the current time
logger.Debug("FormatExport complete")
dest, err := os.CreateTemp("", zipFileName)
if err != nil {
return err
job.Data[shared.JobDataIsDownloadable] = "false"
if totalWarningCount > 0 {
w.setJobWarning(logger, job)
} else {
w.setJobSuccess(logger, job)
}
defer os.Remove(dest.Name())
// Create a new zip archive.
w := zip.NewWriter(dest)
// create a 32 KiB buffer for copying files
buf := make([]byte, 32*1024)
// Add directories to the archive.
for _, directory := range directories {
err = addFiles(w, fileBackend, directory, buf)
if err != nil {
return err
}
}
// Make sure to check the error on Close.
err = w.Close()
if err != nil {
return fmt.Errorf("error closing zip file: %s %v", dest.Name(), err)
}
_, err = dest.Seek(0, 0)
if err != nil {
return fmt.Errorf("error seeking zip file: %s %v", dest.Name(), err)
}
zipPath := path.Join(exportPath, zipFileName)
// If the file backend allows it, we want to upload without a timeout
_, err = filestore.TryWriteFileContext(rctx.Context(), fileBackend, dest, zipPath)
return err
}
func addFiles(w *zip.Writer, fileBackend filestore.FileBackend, basePath string, buf []byte) error {
// Open the Directory
files, err := fileBackend.ListDirectoryRecursively(basePath)
if err != nil {
return err
}
for _, file := range files {
err = addFile(w, fileBackend, file, basePath, buf)
if err != nil {
return err
}
}
return nil
}
func addFile(w *zip.Writer, fileBackend filestore.FileBackend, file, basePath string, buf []byte) error {
// In some storage backends like Hitachi HCP, the first entry
// from a ListObjects API is always the dir entry itself.
if file == basePath {
return nil
}
size, err := fileBackend.FileSize(file)
if err != nil {
return fmt.Errorf("error reading file size for %s: %w", file, err)
}
if size == 0 {
// skip empty files
return nil
}
r, err := fileBackend.Reader(file)
if err != nil {
return fmt.Errorf("error opening file %s: %w", file, err)
}
defer r.Close()
// Add some files to the archive.
f, err := w.Create(file)
if err != nil {
return fmt.Errorf("error creating file %s in the archive: %w", file, err)
}
_, err = io.CopyBuffer(f, r, buf)
if err != nil {
return fmt.Errorf("error copying file %s into the archive: %w", file, err)
}
return nil
}
// initializes job data if it's missing, allows us to recover from failed or improperly configured jobs
func (worker *MessageExportWorker) initJobData(logger mlog.LoggerIFace, job *model.Job) {
func (w *MessageExportWorker) initJobData(logger mlog.LoggerIFace, job *model.Job, now time.Time) {
if job.Data == nil {
job.Data = make(map[string]string)
}
if _, exists := job.Data[JobDataMessagesExported]; !exists {
job.Data[JobDataMessagesExported] = "0"
if _, exists := job.Data[shared.JobDataMessagesExported]; !exists {
logger.Info("Worker: JobDataMessagesExported does not exist, starting at 0")
job.Data[shared.JobDataMessagesExported] = "0"
}
if _, exists := job.Data[JobDirectories]; !exists {
// json null value
job.Data[JobDirectories] = "null"
if _, exists := job.Data[shared.JobDataExportType]; !exists {
exportFormat := *w.jobServer.Config().MessageExportSettings.ExportFormat
logger.Info("Worker: Defaulting to configured export format", mlog.String("export_format", exportFormat))
job.Data[shared.JobDataExportType] = exportFormat
}
if _, exists := job.Data[JobDataExportType]; !exists {
// for now, we'll default to Actiance. When we support multiple export types, we'll have to fetch it from config instead
logger.Info("Worker: Defaulting to configured export format")
job.Data[JobDataExportType] = *worker.jobServer.Config().MessageExportSettings.ExportFormat
if _, exists := job.Data[shared.JobDataBatchSize]; !exists {
batchSize := strconv.Itoa(*w.jobServer.Config().MessageExportSettings.BatchSize)
logger.Info("Worker: Defaulting to configured batch size", mlog.String("batch_size", batchSize))
job.Data[shared.JobDataBatchSize] = batchSize
}
if _, exists := job.Data[JOB_DATA_BatchSize]; !exists {
logger.Info("Worker: Defaulting to configured batch size")
job.Data[JOB_DATA_BatchSize] = strconv.Itoa(*worker.jobServer.Config().MessageExportSettings.BatchSize)
if _, exists := job.Data[shared.JobDataChannelBatchSize]; !exists {
channelBatchSize := strconv.Itoa(*w.jobServer.Config().MessageExportSettings.ChannelBatchSize)
logger.Info("Worker: Defaulting to configured channel batch size", mlog.String("channel_batch_size", channelBatchSize))
job.Data[shared.JobDataChannelBatchSize] = channelBatchSize
}
if _, exists := job.Data[JobDataBatchStartTimestamp]; !exists {
previousJob, err := worker.jobServer.Store.Job().GetNewestJobByStatusesAndType([]string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport)
if _, exists := job.Data[shared.JobDataChannelHistoryBatchSize]; !exists {
channelHistoryBatchSize := strconv.Itoa(*w.jobServer.Config().MessageExportSettings.ChannelHistoryBatchSize)
logger.Info("Worker: Defaulting to configured channel history batch size", mlog.String("channel_history_batch_size", channelHistoryBatchSize))
job.Data[shared.JobDataChannelHistoryBatchSize] = channelHistoryBatchSize
}
if _, exists := job.Data[shared.JobDataBatchNumber]; !exists {
logger.Info("Worker: JobDataBatchNumber does not exist, starting at 0")
job.Data[shared.JobDataBatchNumber] = "0"
}
// If this is a new job (JobEndTime doesn't exist), set it to now, because this is when the job has first started.
// The logic is that a job exports messages up to the moment the job was started. If the job was picked up after
// gracefully stopping, then run it until that original initial endTime.
// However, if the job was cancelled or errored out, that job will not be picked up again, so this will be a new job
// starting from the last successful batchStartTimestamp up until now. This is intentional (for now) because failed
// jobs do not get rescheduled properly yet, and when they are run again it means that new day's worth of messages
// need to be exported.
if _, exists := job.Data[shared.JobDataJobEndTime]; !exists {
millis := strconv.FormatInt(model.GetMillisForTime(now), 10)
logger.Info("Worker: JobDataJobEndTime not found in previous job, using now", mlog.String("job_data_job_end_time", millis))
job.Data[shared.JobDataJobEndTime] = millis
}
if _, exists := job.Data[shared.JobDataBatchStartTime]; !exists {
previousJob, err := w.jobServer.Store.Job().GetNewestJobByStatusesAndType([]string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport)
if err != nil {
logger.Info("Worker: No previously successful job found, falling back to configured MessageExportSettings.ExportFromTimestamp")
job.Data[JobDataBatchStartTimestamp] = strconv.FormatInt(*worker.jobServer.Config().MessageExportSettings.ExportFromTimestamp, 10)
job.Data[JobDataBatchStartId] = ""
job.Data[JobDataStartTimestamp] = job.Data[JobDataBatchStartTimestamp]
job.Data[JobDataStartId] = job.Data[JobDataBatchStartId]
exportFromTimestamp := strconv.FormatInt(*w.jobServer.Config().MessageExportSettings.ExportFromTimestamp, 10)
logger.Info("Worker: No previously successful job found, falling back to configured MessageExportSettings.ExportFromTimestamp", mlog.String("export_from_timestamp", exportFromTimestamp))
job.Data[shared.JobDataBatchStartTime] = exportFromTimestamp
job.Data[shared.JobDataJobStartTime] = exportFromTimestamp
job.Data[shared.JobDataBatchStartId] = ""
job.Data[shared.JobDataJobStartId] = job.Data[shared.JobDataBatchStartId]
job.Data[shared.JobDataExportDir] = getJobExportDir(logger, job.Data, exportFromTimestamp, job.Data[shared.JobDataJobEndTime])
return
}
@@ -508,72 +327,174 @@ func (worker *MessageExportWorker) initJobData(logger mlog.LoggerIFace, job *mod
if previousJob.Data == nil {
previousJob.Data = make(map[string]string)
}
if _, prevExists := previousJob.Data[JobDataBatchStartTimestamp]; !prevExists {
logger.Info("Worker: Previously successful job lacks job data, falling back to configured MessageExportSettings.ExportFromTimestamp")
job.Data[JobDataBatchStartTimestamp] = strconv.FormatInt(*worker.jobServer.Config().MessageExportSettings.ExportFromTimestamp, 10)
if _, prevExists := previousJob.Data[shared.JobDataBatchStartTime]; !prevExists {
exportFromTimestamp := strconv.FormatInt(*w.jobServer.Config().MessageExportSettings.ExportFromTimestamp, 10)
logger.Info("Worker: Previously successful job lacks job data, falling back to configured MessageExportSettings.ExportFromTimestamp", mlog.String("export_from_timestamp", exportFromTimestamp))
job.Data[shared.JobDataBatchStartTime] = exportFromTimestamp
job.Data[shared.JobDataJobStartTime] = exportFromTimestamp
} else {
job.Data[JobDataBatchStartTimestamp] = previousJob.Data[JobDataBatchStartTimestamp]
job.Data[shared.JobDataBatchStartTime] = previousJob.Data[shared.JobDataBatchStartTime]
}
if _, prevExists := previousJob.Data[JobDataBatchStartId]; !prevExists {
if _, prevExists := previousJob.Data[shared.JobDataBatchStartId]; !prevExists {
logger.Info("Worker: Previously successful job lacks post ID, falling back to empty string")
job.Data[JobDataBatchStartId] = ""
job.Data[shared.JobDataBatchStartId] = ""
} else {
job.Data[JobDataBatchStartId] = previousJob.Data[JobDataBatchStartId]
job.Data[shared.JobDataBatchStartId] = previousJob.Data[shared.JobDataBatchStartId]
}
job.Data[JobDataStartTimestamp] = job.Data[JobDataBatchStartTimestamp]
job.Data[JobDataStartId] = job.Data[JobDataBatchStartId]
job.Data[shared.JobDataJobStartId] = job.Data[shared.JobDataBatchStartId]
} else {
logger.Info("Worker: FormatExport start time explicitly set", mlog.String("new_start_time", job.Data[JobDataBatchStartTimestamp]))
logger.Info("Worker: JobDataBatchStartTime start time was already set",
mlog.String(shared.JobDataBatchStartTime, job.Data[shared.JobDataBatchStartTime]))
}
if _, exists := job.Data[shared.JobDataJobStartTime]; !exists {
// Just in case, if we don't have this (JobDataBatchStartTime was already set, but this wasn't) set it:
job.Data[shared.JobDataJobStartTime] = job.Data[shared.JobDataBatchStartTime]
logger.Info("Worker: JobDataJobStartTime start time was not set, using batch startTimestamp",
mlog.String(shared.JobDataJobStartTime, job.Data[shared.JobDataJobStartTime]))
}
job.Data[shared.JobDataExportDir] = getJobExportDir(logger, job.Data, job.Data[shared.JobDataJobStartTime], job.Data[shared.JobDataJobEndTime])
}
func getJobProgress(totalExportedPosts, totalPosts int64) int64 {
return totalExportedPosts * 100 / totalPosts
func extractJobData(logger *mlog.Logger, strmap map[string]string) (shared.JobData, error) {
data, err := shared.StringMapToJobDataWithZeroValues(strmap)
if err != nil {
return data, err
}
// ExportPeriodStartTime is initialized to BatchStartTime because this is where we will start exporting. But unlike
// BatchStartTime, it won't change as we process the batches.
// If this is the first time this job has run, BatchStartTime will be the start of the entire job. If this job has
// been resumed, then BatchStartTime will be the start of the newest batch. This is expected--the channel activity
// and total posts will be calculated from ExportPeriodStartTime (anything earlier has already been exported in
// previous batches).
// Note: ExportPeriodStartTime is different from JobStartTime because JobStartTime won't change
// if the job processes some batches, is stopped, and picked up again.
data.ExportPeriodStartTime = data.BatchStartTime
logger.Info("Worker: initial job variables set",
mlog.String("export_type", data.ExportType),
mlog.String("export_dir", data.ExportDir),
mlog.Int("job_start_time", data.JobStartTime),
mlog.Int("batch_start_time", data.BatchStartTime),
mlog.Int("export_period_start_time", data.ExportPeriodStartTime),
mlog.Int("job_end_time", data.JobEndTime),
mlog.String("job_start_id", data.JobStartId),
mlog.Int("batch_size", data.BatchSize),
mlog.Int("channel_batch_size", data.ChannelBatchSize),
mlog.Int("channel_history_batch_size", data.ChannelHistoryBatchSize),
mlog.Int("batch_number", data.BatchNumber),
mlog.Int("total_posts_exported", data.MessagesExported))
return data, err
}
func (worker *MessageExportWorker) setJobSuccess(logger mlog.LoggerIFace, job *model.Job) {
// setting progress causes the job data to be saved, which is necessary if we want the next job to pick up where this one left off
if err := worker.jobServer.SetJobProgress(job, 100); err != nil {
func setJobDataEndOfBatch(job *model.Job, data shared.JobData) {
job.Data[shared.JobDataBatchStartTime] = strconv.FormatInt(data.BatchStartTime, 10)
job.Data[shared.JobDataBatchStartId] = data.Cursor.LastPostId
job.Data[shared.JobDataMessagesExported] = strconv.Itoa(data.MessagesExported)
job.Data[shared.JobDataBatchNumber] = strconv.Itoa(data.BatchNumber)
}
// getJobExportDir will use the existing JobDataExportDir if available. If it's not available, this is the first run
// for the job, so we use the startTime and endTime passed in.
func getJobExportDir(logger mlog.LoggerIFace, data model.StringMap, startTime string, endTime string) string {
exportDir, exists := data[shared.JobDataExportDir]
if !exists {
// If we don't have a jobDataExportDir, this is the first run for the job, so we use the batch startTime
exportDir = path.Join(model.ComplianceExportPath, fmt.Sprintf("%s-%s-%s", time.Now().Format(model.ComplianceExportDirectoryFormat), startTime, endTime))
logger.Info("Worker: JobDataExportDir does not exist, using current datetime", mlog.String("job_data_export_dir", exportDir))
}
return exportDir
}
func getJobProgress(totalExportedPosts, totalPostsExpected int) int {
return totalExportedPosts * 100 / totalPostsExpected
}
func (w *MessageExportWorker) setJobProgressMessage(progress int64, message string, logger mlog.LoggerIFace, job *model.Job) {
job.Status = model.JobStatusInProgress
job.Progress = progress
if job.Data == nil {
job.Data = make(map[string]string)
}
job.Data["progress_message"] = message
if _, err := w.jobServer.Store.Job().UpdateOptimistically(job, model.JobStatusInProgress); err != nil {
logger.Error("Worker: Failed to update progress for job", mlog.Err(err))
worker.setJobError(logger, job, err)
}
if err := worker.jobServer.SetJobSuccess(job); err != nil {
}
func (w *MessageExportWorker) setJobProgress(logger mlog.LoggerIFace, job *model.Job, progress int) error {
if job.Data != nil {
job.Data["progress_message"] = ""
}
if err := w.jobServer.SetJobProgress(job, int64(progress)); err != nil {
logger.Error("Worker: Failed to update progress for job", mlog.Err(err))
w.setJobError(logger, job, err)
return err
}
return nil
}
func (w *MessageExportWorker) setJobSuccess(logger mlog.LoggerIFace, job *model.Job) {
// setting progress causes the job data to be saved, which is necessary if we want the next job to pick up where this one left off
if job.Data != nil {
job.Data["progress_message"] = ""
}
if err := w.jobServer.SetJobProgress(job, 100); err != nil {
logger.Error("Worker: Failed to update progress for job", mlog.Err(err))
w.setJobError(logger, job, err)
}
if err := w.jobServer.SetJobSuccess(job); err != nil {
logger.Error("Worker: Failed to set success for job", mlog.Err(err))
worker.setJobError(logger, job, err)
w.setJobError(logger, job, err)
}
}
func (worker *MessageExportWorker) setJobWarning(logger mlog.LoggerIFace, job *model.Job) {
func (w *MessageExportWorker) setJobWarning(logger mlog.LoggerIFace, job *model.Job) {
// setting progress causes the job data to be saved, which is necessary if we want the next job to pick up where this one left off
if err := worker.jobServer.SetJobProgress(job, 100); err != nil {
if job.Data != nil {
job.Data["progress_message"] = ""
}
if err := w.jobServer.SetJobProgress(job, 100); err != nil {
logger.Error("Worker: Failed to update progress for job", mlog.Err(err))
worker.setJobError(logger, job, err)
w.setJobError(logger, job, err)
}
if err := worker.jobServer.SetJobWarning(job); err != nil {
if err := w.jobServer.SetJobWarning(job); err != nil {
logger.Error("Worker: Failed to set warning for job", mlog.Err(err))
worker.setJobError(logger, job, err)
w.setJobError(logger, job, err)
}
}
func (worker *MessageExportWorker) setJobError(logger mlog.LoggerIFace, job *model.Job, appError *model.AppError) {
func (w *MessageExportWorker) setJobError(logger mlog.LoggerIFace, job *model.Job, appError *model.AppError) {
if job.Data != nil {
job.Data["progress_message"] = ""
}
logger.Error("Worker: Job error", mlog.Err(appError))
if err := worker.jobServer.SetJobError(job, appError); err != nil {
logger.Error("Worker: Failed to set job errorv", mlog.Err(err), mlog.NamedErr("set_error", appError))
if err := w.jobServer.SetJobError(job, appError); err != nil {
logger.Error("Worker: Failed to set job error", mlog.Err(err), mlog.NamedErr("set_error", appError))
}
}
func (worker *MessageExportWorker) setJobCanceled(logger mlog.LoggerIFace, job *model.Job) {
if err := worker.jobServer.SetJobCanceled(job); err != nil {
func (w *MessageExportWorker) setJobCanceled(logger mlog.LoggerIFace, job *model.Job) {
if job.Data != nil {
job.Data["progress_message"] = ""
}
if err := w.jobServer.SetJobCanceled(job); err != nil {
logger.Error("Worker: Failed to mark job as canceled", mlog.Err(err))
}
}
func (worker *MessageExportWorker) SetJobPending(logger mlog.LoggerIFace, job *model.Job) {
if err := worker.jobServer.SetJobPending(job); err != nil {
func (w *MessageExportWorker) SetJobPending(logger mlog.LoggerIFace, job *model.Job) {
if job.Data != nil {
job.Data["progress_message"] = ""
}
if err := w.jobServer.SetJobPending(job); err != nil {
logger.Error("Worker: Failed to mark job as pending", mlog.Err(err))
}
}
func getOutputDirectoryPath(exportStartTime int64, exportEndTime int64) string {
return path.Join(exportPath, strconv.FormatInt(exportStartTime, 10)+"-"+strconv.FormatInt(exportEndTime, 10))
}

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

@@ -4,14 +4,14 @@
package message_export
import (
"archive/zip"
"bytes"
"context"
"net/http"
"errors"
"fmt"
"os"
"path"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
tmock "github.com/stretchr/testify/mock"
@@ -24,10 +24,10 @@ import (
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
st "github.com/mattermost/mattermost/server/v8/channels/store/storetest"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
fmocks "github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks"
"github.com/mattermost/mattermost/server/v8/enterprise/message_export/shared"
)
func TestInitJobDataNoJobData(t *testing.T) {
@@ -36,14 +36,14 @@ func TestInitJobDataNoJobData(t *testing.T) {
defer mockStore.AssertExpectations(t)
job := &model.Job{
Id: model.NewId(),
Id: st.NewTestID(),
CreateAt: model.GetMillis(),
Status: model.JobStatusPending,
Type: model.JobTypeMessageExport,
}
// mock job store doesn't return a previously successful job, forcing fallback to config
mockStore.JobStore.On("GetNewestJobByStatusesAndType", []string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport).Return(nil, model.NewAppError("", "", nil, "", http.StatusBadRequest))
mockStore.JobStore.On("GetNewestJobByStatusesAndType", []string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport).Return(nil, errors.New("test"))
worker := &MessageExportWorker{
jobServer: &jobs.JobServer{
@@ -52,11 +52,13 @@ func TestInitJobDataNoJobData(t *testing.T) {
Cfg: &model.Config{
// mock config
MessageExportSettings: model.MessageExportSettings{
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
ChannelBatchSize: model.NewPointer(100),
ChannelHistoryBatchSize: model.NewPointer(100),
},
},
},
@@ -64,12 +66,14 @@ func TestInitJobDataNoJobData(t *testing.T) {
logger: logger,
}
// actually execute the code under test
worker.initJobData(logger, job)
now := time.Now()
worker.initJobData(logger, job, now)
assert.Equal(t, model.ComplianceExportTypeActiance, job.Data[JobDataExportType])
assert.Equal(t, strconv.Itoa(*worker.jobServer.Config().MessageExportSettings.BatchSize), job.Data[JOB_DATA_BatchSize])
assert.Equal(t, strconv.FormatInt(*worker.jobServer.Config().MessageExportSettings.ExportFromTimestamp, 10), job.Data[JobDataBatchStartTimestamp])
assert.Equal(t, model.ComplianceExportTypeActiance, job.Data[shared.JobDataExportType])
assert.Equal(t, strconv.Itoa(*worker.jobServer.Config().MessageExportSettings.BatchSize), job.Data[shared.JobDataBatchSize])
assert.Equal(t, strconv.FormatInt(*worker.jobServer.Config().MessageExportSettings.ExportFromTimestamp, 10), job.Data[shared.JobDataBatchStartTime])
expectedDir := path.Join(model.ComplianceExportPath, fmt.Sprintf("%s-%d-%d", now.Format(model.ComplianceExportDirectoryFormat), 0, now.UnixMilli()))
assert.Equal(t, expectedDir, job.Data[shared.JobDataExportDir])
}
func TestInitJobDataPreviousJobNoJobData(t *testing.T) {
@@ -78,7 +82,7 @@ func TestInitJobDataPreviousJobNoJobData(t *testing.T) {
defer mockStore.AssertExpectations(t)
previousJob := &model.Job{
Id: model.NewId(),
Id: st.NewTestID(),
CreateAt: model.GetMillis(),
Status: model.JobStatusSuccess,
Type: model.JobTypeMessageExport,
@@ -87,7 +91,7 @@ func TestInitJobDataPreviousJobNoJobData(t *testing.T) {
}
job := &model.Job{
Id: model.NewId(),
Id: st.NewTestID(),
CreateAt: model.GetMillis(),
Status: model.JobStatusPending,
Type: model.JobTypeMessageExport,
@@ -103,11 +107,13 @@ func TestInitJobDataPreviousJobNoJobData(t *testing.T) {
Cfg: &model.Config{
// mock config
MessageExportSettings: model.MessageExportSettings{
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
ChannelBatchSize: model.NewPointer(100),
ChannelHistoryBatchSize: model.NewPointer(100),
},
},
},
@@ -115,12 +121,14 @@ func TestInitJobDataPreviousJobNoJobData(t *testing.T) {
logger: logger,
}
// actually execute the code under test
worker.initJobData(logger, job)
now := time.Now()
worker.initJobData(logger, job, now)
assert.Equal(t, model.ComplianceExportTypeActiance, job.Data[JobDataExportType])
assert.Equal(t, strconv.Itoa(*worker.jobServer.Config().MessageExportSettings.BatchSize), job.Data[JOB_DATA_BatchSize])
assert.Equal(t, strconv.FormatInt(*worker.jobServer.Config().MessageExportSettings.ExportFromTimestamp, 10), job.Data[JobDataBatchStartTimestamp])
assert.Equal(t, model.ComplianceExportTypeActiance, job.Data[shared.JobDataExportType])
assert.Equal(t, strconv.Itoa(*worker.jobServer.Config().MessageExportSettings.BatchSize), job.Data[shared.JobDataBatchSize])
assert.Equal(t, strconv.FormatInt(*worker.jobServer.Config().MessageExportSettings.ExportFromTimestamp, 10), job.Data[shared.JobDataBatchStartTime])
expectedDir := path.Join(model.ComplianceExportPath, fmt.Sprintf("%s-%d-%d", now.Format(model.ComplianceExportDirectoryFormat), 0, now.UnixMilli()))
assert.Equal(t, expectedDir, job.Data[shared.JobDataExportDir])
}
func TestInitJobDataPreviousJobWithJobData(t *testing.T) {
@@ -129,20 +137,21 @@ func TestInitJobDataPreviousJobWithJobData(t *testing.T) {
defer mockStore.AssertExpectations(t)
previousJob := &model.Job{
Id: model.NewId(),
Id: st.NewTestID(),
CreateAt: model.GetMillis(),
Status: model.JobStatusSuccess,
Type: model.JobTypeMessageExport,
StartAt: model.GetMillis() - 1000,
LastActivityAt: model.GetMillis() - 1000,
Data: map[string]string{JobDataBatchStartTimestamp: "123"},
Data: map[string]string{shared.JobDataBatchStartTime: "123"},
}
job := &model.Job{
Id: model.NewId(),
Id: st.NewTestID(),
CreateAt: model.GetMillis(),
Status: model.JobStatusPending,
Type: model.JobTypeMessageExport,
Data: map[string]string{shared.JobDataExportDir: "this-is-the-export-dir"},
}
// mock job store returns a previously successful job that has the config that we're looking for, so we use it
@@ -155,11 +164,13 @@ func TestInitJobDataPreviousJobWithJobData(t *testing.T) {
Cfg: &model.Config{
// mock config
MessageExportSettings: model.MessageExportSettings{
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
ChannelBatchSize: model.NewPointer(100),
ChannelHistoryBatchSize: model.NewPointer(100),
},
},
},
@@ -167,12 +178,14 @@ func TestInitJobDataPreviousJobWithJobData(t *testing.T) {
logger: logger,
}
// actually execute the code under test
worker.initJobData(logger, job)
now := time.Now()
worker.initJobData(logger, job, now)
assert.Equal(t, model.ComplianceExportTypeActiance, job.Data[JobDataExportType])
assert.Equal(t, strconv.Itoa(*worker.jobServer.Config().MessageExportSettings.BatchSize), job.Data[JOB_DATA_BatchSize])
assert.Equal(t, previousJob.Data[JobDataBatchStartTimestamp], job.Data[JobDataBatchStartTimestamp])
assert.Equal(t, model.ComplianceExportTypeActiance, job.Data[shared.JobDataExportType])
assert.Equal(t, strconv.Itoa(*worker.jobServer.Config().MessageExportSettings.BatchSize), job.Data[shared.JobDataBatchSize])
assert.Equal(t, previousJob.Data[shared.JobDataBatchStartTime], job.Data[shared.JobDataBatchStartTime])
expectedDir := "this-is-the-export-dir"
assert.Equal(t, expectedDir, job.Data[shared.JobDataExportDir])
}
func TestDoJobNoPostsToExport(t *testing.T) {
@@ -185,7 +198,7 @@ func TestDoJobNoPostsToExport(t *testing.T) {
defer mockMetrics.AssertExpectations(t)
job := &model.Job{
Id: model.NewId(),
Id: st.NewTestID(),
CreateAt: model.GetMillis(),
Status: model.JobStatusPending,
Type: model.JobTypeMessageExport,
@@ -196,15 +209,19 @@ func TestDoJobNoPostsToExport(t *testing.T) {
mockMetrics.On("IncrementJobActive", model.JobTypeMessageExport)
// no previous job, data will be loaded from config
mockStore.JobStore.On("GetNewestJobByStatusesAndType", []string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport).Return(nil, model.NewAppError("", "", nil, "", http.StatusBadRequest))
mockStore.JobStore.On("GetNewestJobByStatusesAndType", []string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport).Return(nil, errors.New("test"))
// no channels with activity
mockStore.ChannelMemberHistoryStore.On("GetChannelsWithActivityDuring", mock.Anything, mock.Anything).
Return(make([]string, 0), nil)
// no posts found to export
mockStore.ComplianceStore.On("MessageExport", mock.Anything, mock.AnythingOfType("model.MessageExportCursor"), 10000).Return(
mockStore.ComplianceStore.On("MessageExport", mock.Anything, mock.AnythingOfType("model.MessageExportCursor"), 10001).Return(
make([]*model.MessageExport, 0), model.MessageExportCursor{}, nil,
)
mockStore.PostStore.On("AnalyticsPostCount", mock.Anything).Return(
int64(estimatedPostCount), nil,
int64(shared.EstimatedPostCount), nil,
)
// job completed successfully
@@ -229,11 +246,13 @@ func TestDoJobNoPostsToExport(t *testing.T) {
Directory: model.NewPointer(tempDir),
},
MessageExportSettings: model.MessageExportSettings{
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
ChannelBatchSize: model.NewPointer(100),
ChannelHistoryBatchSize: model.NewPointer(100),
},
},
},
@@ -258,7 +277,7 @@ func TestDoJobWithDedicatedExportBackend(t *testing.T) {
defer mockMetrics.AssertExpectations(t)
job := &model.Job{
Id: model.NewId(),
Id: st.NewTestID(),
CreateAt: model.GetMillis(),
Status: model.JobStatusPending,
Type: model.JobTypeMessageExport,
@@ -269,15 +288,50 @@ func TestDoJobWithDedicatedExportBackend(t *testing.T) {
mockMetrics.On("IncrementJobActive", model.JobTypeMessageExport)
// no previous job, data will be loaded from config
mockStore.JobStore.On("GetNewestJobByStatusesAndType", []string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport).Return(nil, model.NewAppError("", "", nil, "", http.StatusBadRequest))
mockStore.JobStore.On("GetNewestJobByStatusesAndType", []string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport).Return(nil, errors.New("test"))
// no posts found to export
mockStore.ComplianceStore.On("MessageExport", mock.Anything, mock.AnythingOfType("model.MessageExportCursor"), 10000).Return(
channelId := st.NewTestID()
channelName := st.NewTestID()
channelDisplayName := st.NewTestID()
channelType := model.ChannelTypeOpen
messages := []*model.MessageExport{
{
TeamId: model.NewPointer(st.NewTestID()),
ChannelId: model.NewPointer(channelId),
ChannelName: model.NewPointer(channelName),
UserId: model.NewPointer(st.NewTestID()),
UserEmail: model.NewPointer(st.NewTestID()),
Username: model.NewPointer(st.NewTestID()),
PostId: model.NewPointer(st.NewTestID()),
PostCreateAt: model.NewPointer[int64](123),
PostUpdateAt: model.NewPointer[int64](123),
PostDeleteAt: model.NewPointer[int64](123),
PostMessage: model.NewPointer(st.NewTestID()),
},
}
// need to export at least one post to make an export directory and file
mockStore.ChannelMemberHistoryStore.On("GetChannelsWithActivityDuring", mock.Anything, mock.Anything).
Return([]string{*messages[0].ChannelId}, nil)
mockStore.ChannelStore.On("GetMany", []string{channelId}, true).
Return(model.ChannelList{{
Id: channelId,
DisplayName: channelDisplayName,
Name: channelName,
Type: channelType,
}}, nil)
mockStore.ComplianceStore.On("MessageExport", mock.Anything, mock.AnythingOfType("model.MessageExportCursor"), 10001).Return(
messages, model.MessageExportCursor{}, nil,
).Once()
mockStore.ComplianceStore.On("MessageExport", mock.Anything, mock.AnythingOfType("model.MessageExportCursor"), 10001).Return(
make([]*model.MessageExport, 0), model.MessageExportCursor{}, nil,
)
).Once()
mockStore.ChannelMemberHistoryStore.On("GetUsersInChannelDuring", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
mockStore.PostStore.On("AnalyticsPostCount", mock.Anything).Return(
int64(estimatedPostCount), nil,
int64(1), nil,
)
// job completed successfully
@@ -309,11 +363,13 @@ func TestDoJobWithDedicatedExportBackend(t *testing.T) {
ExportDirectory: model.NewPointer(tempDedicatedDir),
},
MessageExportSettings: model.MessageExportSettings{
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
ChannelBatchSize: model.NewPointer(100),
ChannelHistoryBatchSize: model.NewPointer(100),
},
},
},
@@ -347,7 +403,7 @@ func TestDoJobCancel(t *testing.T) {
t.Cleanup(func() { mockMetrics.AssertExpectations(t) })
job := &model.Job{
Id: model.NewId(),
Id: st.NewTestID(),
CreateAt: model.GetMillis(),
Status: model.JobStatusPending,
Type: model.JobTypeMessageExport,
@@ -368,11 +424,13 @@ func TestDoJobCancel(t *testing.T) {
Directory: model.NewPointer(tempDir),
},
MessageExportSettings: model.MessageExportSettings{
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
EnableExport: model.NewPointer(true),
ExportFormat: model.NewPointer(model.ComplianceExportTypeActiance),
DailyRunTime: model.NewPointer("01:00"),
ExportFromTimestamp: model.NewPointer(int64(0)),
BatchSize: model.NewPointer(10000),
ChannelBatchSize: model.NewPointer(100),
ChannelHistoryBatchSize: model.NewPointer(100),
},
},
},
@@ -390,11 +448,26 @@ func TestDoJobCancel(t *testing.T) {
mockMetrics.On("IncrementJobActive", model.JobTypeMessageExport)
// No previous job, data will be loaded from config
mockStore.JobStore.On("GetNewestJobByStatusesAndType", []string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport).Return(nil, model.NewAppError("", "", nil, "", http.StatusBadRequest))
mockStore.JobStore.On("GetNewestJobByStatusesAndType", []string{model.JobStatusWarning, model.JobStatusSuccess}, model.JobTypeMessageExport).Return(nil, errors.New("test"))
// Job updates the system console UI, once for getting channels, once for getting activity
mockStore.JobStore.On("UpdateOptimistically", mock.AnythingOfType("*model.Job"), model.JobStatusInProgress).Return(true, nil).Times(2)
// a few calls pass
mockStore.ChannelMemberHistoryStore.On("GetChannelsWithActivityDuring", mock.Anything, mock.Anything).
Return([]string{"channel-id"}, nil)
mockStore.ChannelStore.On("GetMany", []string{"channel-id"}, true).
Return(model.ChannelList{{
Id: "channel-id",
DisplayName: "channel-display-name",
Name: "channel-name",
Type: model.ChannelTypeDirect,
}}, nil)
mockStore.ChannelMemberHistoryStore.On("GetUsersInChannelDuring", mock.Anything, mock.Anything, []string{"channel-id"}).Return([]*model.ChannelMemberHistoryResult{}, nil)
cancelled := make(chan struct{})
// Cancel the worker and return an error
mockStore.ComplianceStore.On("MessageExport", mock.Anything, mock.AnythingOfType("model.MessageExportCursor"), 10000).Run(func(args tmock.Arguments) {
mockStore.ComplianceStore.On("MessageExport", mock.Anything, mock.AnythingOfType("model.MessageExportCursor"), 10001).Run(func(args tmock.Arguments) {
worker.cancel()
rctx, ok := args.Get(0).(request.CTX)
@@ -408,7 +481,7 @@ func TestDoJobCancel(t *testing.T) {
)
mockStore.PostStore.On("AnalyticsPostCount", mock.Anything).Return(
int64(estimatedPostCount), nil,
int64(shared.EstimatedPostCount), nil,
)
// Job marked as pending
@@ -425,75 +498,3 @@ func TestDoJobCancel(t *testing.T) {
// Cleanup
worker.Stop()
}
func TestCreateZipFile(t *testing.T) {
rctx := request.TestContext(t)
tempDir, ioErr := os.MkdirTemp("", "")
require.NoError(t, ioErr)
defer os.RemoveAll(tempDir)
config := filestore.FileBackendSettings{
DriverName: model.ImageDriverLocal,
Directory: tempDir,
}
fileBackend, err := filestore.NewFileBackend(config)
assert.NoError(t, err)
_ = fileBackend
b := []byte("test")
path1 := path.Join(exportPath, "19700101")
path2 := path.Join(exportPath, "19800101/subdir")
// We test with a mock to test the Hitachi HCP case
// where ListDirectory returns the dir itself as the first entry.
// Note: If the mocks fail, that means the logic in createZipFile has
// gone wrong and needs to be verified.
mock := &fmocks.FileBackend{}
defer mock.AssertExpectations(t)
mock.On("WriteFile", tmock.Anything, tmock.AnythingOfType("string")).Return(int64(4), nil)
mock.On("FileSize", tmock.Anything).Return(int64(4), nil)
mock.On("FileSize", tmock.Anything).Return(int64(4), nil)
mock.On("Reader", path.Join(path1, "testid")).Return(mockReadSeekCloser{bytes.NewReader([]byte("test"))}, nil)
mock.On("Reader", path.Join(path2, "testid")).Return(mockReadSeekCloser{bytes.NewReader([]byte("test"))}, nil)
mock.On("ListDirectoryRecursively", path1).Return([]string{path1, path.Join(path1, "testid")}, nil)
mock.On("ListDirectoryRecursively", path2).Return([]string{path2, path.Join(path2, "testid")}, nil)
for i, backend := range []filestore.FileBackend{fileBackend, mock} {
written, err := backend.WriteFile(bytes.NewReader(b), path1+"/"+model.NewId())
assert.NoError(t, err)
assert.Equal(t, int64(len(b)), written)
written, err = backend.WriteFile(bytes.NewReader(b), path2+"/"+model.NewId())
assert.NoError(t, err)
assert.Equal(t, int64(len(b)), written)
written, err = backend.WriteFile(bytes.NewReader(b), path2+"/"+model.NewId())
assert.NoError(t, err)
assert.Equal(t, int64(len(b)), written)
err = createZipFile(rctx, backend, "testjob", []string{path1, path2})
assert.NoError(t, err)
// Skip checking the zip file in mock case.
if i == 1 {
continue
}
r, err := zip.OpenReader(path.Join(tempDir, exportPath) + "/testjob.zip")
assert.NoError(t, err)
err = r.Close()
require.NoError(t, err)
assert.Equal(t, 3, len(r.File))
}
}
type mockReadSeekCloser struct {
*bytes.Reader
}
func (r mockReadSeekCloser) Close() error {
return nil
}

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

@@ -7576,14 +7576,6 @@
"id": "ent.account_migration.get_saml_users_failed",
"translation": "Unable to get SAML users."
},
{
"id": "ent.actiance.export.marshalToXml.appError",
"translation": "Unable to convert export to XML."
},
{
"id": "ent.actiance.export.write_file.appError",
"translation": "Unable to write the export file."
},
{
"id": "ent.api.post.send_notifications_and_forget.push_image_only",
"translation": " attached a file."
@@ -7604,86 +7596,10 @@
"id": "ent.cluster.timeout.error",
"translation": "Timed out waiting for cluster response"
},
{
"id": "ent.compliance.bad_export_type.appError",
"translation": "Unknown output format {{.ExportType}}"
},
{
"id": "ent.compliance.csv.attachment.copy.appError",
"translation": "Unable to copy the attachment into the zip file."
},
{
"id": "ent.compliance.csv.attachment.export.appError",
"translation": "Unable to add attachment to the CSV export."
},
{
"id": "ent.compliance.csv.file.creation.appError",
"translation": "Unable to create temporary CSV export file."
},
{
"id": "ent.compliance.csv.header.export.appError",
"translation": "Unable to add header to the CSV export."
},
{
"id": "ent.compliance.csv.metadata.close.appError",
"translation": "Unable to close the zip file"
},
{
"id": "ent.compliance.csv.metadata.export.appError",
"translation": "Unable to add metadata file to the zip file."
},
{
"id": "ent.compliance.csv.metadata.json.marshalling.appError",
"translation": "Unable to convert metadata to json."
},
{
"id": "ent.compliance.csv.metadata.json.zipfile.appError",
"translation": "Unable to create the zip file"
},
{
"id": "ent.compliance.csv.post.export.appError",
"translation": "Unable to export a post."
},
{
"id": "ent.compliance.csv.seek.appError",
"translation": "Unable to seek to start of export file."
},
{
"id": "ent.compliance.csv.warning.appError",
"translation": "Unable to create the warning file."
},
{
"id": "ent.compliance.csv.write_file.appError",
"translation": "Unable to write the csv file."
},
{
"id": "ent.compliance.csv.zip.creation.appError",
"translation": "Unable to create the zip export file."
},
{
"id": "ent.compliance.global_relay.attachments_removed.appError",
"translation": "Uploaded file was removed from Global Relay export because it was too large to send."
},
{
"id": "ent.compliance.global_relay.open_temporary_file.appError",
"translation": "Unable to open the temporary export file."
},
{
"id": "ent.compliance.global_relay.rewind_temporary_file.appError",
"translation": "Unable to re-read the Global Relay temporary export file."
},
{
"id": "ent.compliance.global_relay.write_file.appError",
"translation": "Unable to write the global relay file."
},
{
"id": "ent.compliance.licence_disable.app_error",
"translation": "Compliance functionality disabled by current license. Please contact your system administrator about upgrading your enterprise license."
},
{
"id": "ent.compliance.run_export.template_watcher.appError",
"translation": "Unable to load export templates. Please try again."
},
{
"id": "ent.compliance.run_failed.error",
"translation": "Compliance export failed for job '{{.JobName}}' at '{{.FilePath}}'"
@@ -7944,10 +7860,6 @@
"id": "ent.elasticsearch.test_config.reenter_password",
"translation": "The Search Server URL or Username has changed. Please re-enter the password to test connection."
},
{
"id": "ent.get_users_in_channel_during",
"translation": "Failed to get users in channel during specified time period."
},
{
"id": "ent.id_loaded.license_disable.app_error",
"translation": "Your license does not support ID Loaded Push Notifications."
@@ -8081,72 +7993,20 @@
"translation": "unable to migrate."
},
{
"id": "ent.message_export.actiance_export.get_attachment_error",
"translation": "Failed to get file info for a post."
"id": "ent.message_export.actiance_export.calculate_channel_exports.activity_message",
"translation": "Calculating channel activity: {{.NumCompleted}}/{{.NumChannels}} channels completed."
},
{
"id": "ent.message_export.csv_export.get_attachment_error",
"translation": "Failed to get file info for a post."
"id": "ent.message_export.actiance_export.calculate_channel_exports.channel_message",
"translation": "Exporting channel information for {{.NumChannels}} channels."
},
{
"id": "ent.message_export.global_relay.attach_file.app_error",
"translation": "Unable to add attachment to the Global Relay export."
"id": "ent.message_export.calculate_channel_exports.app_error",
"translation": "Failed to calculate channel export data."
},
{
"id": "ent.message_export.global_relay.close_zip_file.app_error",
"translation": "Unable to close the zip file."
},
{
"id": "ent.message_export.global_relay.create_file_in_zip.app_error",
"translation": "Unable to create the eml file."
},
{
"id": "ent.message_export.global_relay.generate_email.app_error",
"translation": "Unable to generate eml file data."
},
{
"id": "ent.message_export.global_relay_export.deliver.close.app_error",
"translation": "Unable to deliver the email to Global Relay."
},
{
"id": "ent.message_export.global_relay_export.deliver.from_address.app_error",
"translation": "Unable to set the email From address."
},
{
"id": "ent.message_export.global_relay_export.deliver.msg.app_error",
"translation": "Unable to set the email message."
},
{
"id": "ent.message_export.global_relay_export.deliver.msg_data.app_error",
"translation": "Unable to write the email message."
},
{
"id": "ent.message_export.global_relay_export.deliver.parse_mail.app_error",
"translation": "Unable to read the email information."
},
{
"id": "ent.message_export.global_relay_export.deliver.to_address.app_error",
"translation": "Unable to set the email To address."
},
{
"id": "ent.message_export.global_relay_export.deliver.unable_to_connect_smtp_server.app_error",
"translation": "Unable to connect to the smtp server"
},
{
"id": "ent.message_export.global_relay_export.deliver.unable_to_get_file_info.app_error",
"translation": "Unable to get the information of the export temporary file."
},
{
"id": "ent.message_export.global_relay_export.deliver.unable_to_open_email_file.app_error",
"translation": "Unable to get the an email from the temporary file."
},
{
"id": "ent.message_export.global_relay_export.deliver.unable_to_open_zip_file_data.app_error",
"translation": "Unable to open the export temporary file."
},
{
"id": "ent.message_export.global_relay_export.get_attachment_error",
"translation": "Failed to get file info for a post."
"id": "ent.message_export.job_data_conversion.app_error",
"translation": "Failed to convert a value from the job's data field."
},
{
"id": "ent.message_export.run_export.app_error",

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

@@ -234,13 +234,19 @@ const (
PluginSettingsDefaultMarketplaceURL = "https://api.integrations.mattermost.com"
PluginSettingsOldMarketplaceURL = "https://marketplace.integrations.mattermost.com"
ComplianceExportTypeCsv = "csv"
ComplianceExportTypeActiance = "actiance"
ComplianceExportTypeGlobalrelay = "globalrelay"
ComplianceExportTypeGlobalrelayZip = "globalrelay-zip"
GlobalrelayCustomerTypeA9 = "A9"
GlobalrelayCustomerTypeA10 = "A10"
GlobalrelayCustomerTypeCustom = "CUSTOM"
ComplianceExportDirectoryFormat = "compliance-export-2006-01-02-15h04m"
ComplianceExportPath = "export"
ComplianceExportPathCLI = "cli"
ComplianceExportTypeCsv = "csv"
ComplianceExportTypeActiance = "actiance"
ComplianceExportTypeGlobalrelay = "globalrelay"
ComplianceExportTypeGlobalrelayZip = "globalrelay-zip"
ComplianceExportChannelBatchSizeDefault = 100
ComplianceExportChannelHistoryBatchSizeDefault = 10
GlobalrelayCustomerTypeA9 = "A9"
GlobalrelayCustomerTypeA10 = "A10"
GlobalrelayCustomerTypeCustom = "CUSTOM"
ClientSideCertCheckPrimaryAuth = "primary"
ClientSideCertCheckSecondaryAuth = "secondary"
@@ -3403,12 +3409,14 @@ func (s *GlobalRelayMessageExportSettings) SetDefaults() {
}
type MessageExportSettings struct {
EnableExport *bool `access:"compliance_compliance_export"`
ExportFormat *string `access:"compliance_compliance_export"`
DailyRunTime *string `access:"compliance_compliance_export"`
ExportFromTimestamp *int64 `access:"compliance_compliance_export"`
BatchSize *int `access:"compliance_compliance_export"`
DownloadExportResults *bool `access:"compliance_compliance_export"`
EnableExport *bool `access:"compliance_compliance_export"`
ExportFormat *string `access:"compliance_compliance_export"`
DailyRunTime *string `access:"compliance_compliance_export"`
ExportFromTimestamp *int64 `access:"compliance_compliance_export"`
BatchSize *int `access:"compliance_compliance_export"`
DownloadExportResults *bool `access:"compliance_compliance_export"`
ChannelBatchSize *int `access:"compliance_compliance_export"`
ChannelHistoryBatchSize *int `access:"compliance_compliance_export"`
// formatter-specific settings - these are only expected to be non-nil if ExportFormat is set to the associated format
GlobalRelaySettings *GlobalRelayMessageExportSettings `access:"compliance_compliance_export"`
@@ -3439,6 +3447,14 @@ func (s *MessageExportSettings) SetDefaults() {
s.BatchSize = NewPointer(10000)
}
if s.ChannelBatchSize == nil || *s.ChannelBatchSize == 0 {
s.ChannelBatchSize = NewPointer(ComplianceExportChannelBatchSizeDefault)
}
if s.ChannelHistoryBatchSize == nil || *s.ChannelHistoryBatchSize == 0 {
s.ChannelHistoryBatchSize = NewPointer(ComplianceExportChannelHistoryBatchSizeDefault)
}
if s.GlobalRelaySettings == nil {
s.GlobalRelaySettings = &GlobalRelayMessageExportSettings{}
}
@@ -4434,7 +4450,7 @@ func (s *MessageExportSettings) isValid() *AppError {
return NewAppError("Config.IsValid", "model.config.is_valid.message_export.daily_runtime.app_error", nil, "", http.StatusBadRequest).Wrap(err)
} else if s.BatchSize == nil || *s.BatchSize < 0 {
return NewAppError("Config.IsValid", "model.config.is_valid.message_export.batch_size.app_error", nil, "", http.StatusBadRequest)
} else if s.ExportFormat == nil || (*s.ExportFormat != ComplianceExportTypeActiance && *s.ExportFormat != ComplianceExportTypeGlobalrelay && *s.ExportFormat != ComplianceExportTypeCsv) {
} else if s.ExportFormat == nil || (*s.ExportFormat != ComplianceExportTypeActiance && *s.ExportFormat != ComplianceExportTypeGlobalrelay && *s.ExportFormat != ComplianceExportTypeCsv && *s.ExportFormat != ComplianceExportTypeGlobalrelayZip) {
return NewAppError("Config.IsValid", "model.config.is_valid.message_export.export_type.app_error", nil, "", http.StatusBadRequest)
}

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

@@ -10,6 +10,7 @@ import (
const (
JobTypeDataRetention = "data_retention"
JobTypeMessageExport = "message_export"
JobTypeCLIMessageExport = "cli_message_export"
JobTypeElasticsearchPostIndexing = "elasticsearch_post_indexing"
JobTypeElasticsearchPostAggregation = "elasticsearch_post_aggregation"
JobTypeBlevePostIndexing = "bleve_post_indexing"

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

@@ -24,6 +24,7 @@ type MessageExport struct {
PostCreateAt *int64
PostUpdateAt *int64
PostDeleteAt *int64
PostEditAt *int64
PostMessage *string
PostType *string
PostRootId *string
@@ -32,9 +33,12 @@ type MessageExport struct {
PostFileIds StringArray
}
// MessageExportCursor retrieves posts in the inclusive range:
// [LastPostUpdateAt + LastPostId, UntilUpdateAt]
type MessageExportCursor struct {
LastPostUpdateAt int64
LastPostId string
UntilUpdateAt int64
}
// PreviewID returns the value of the post's previewed_post prop, if present, or an empty string.

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

@@ -399,8 +399,11 @@ type PostCountOptions struct {
UsersPostsOnly bool
// AllowFromCache looks up cache only when ExcludeDeleted and UsersPostsOnly are true and rest are falsy.
AllowFromCache bool
SincePostID string
SinceUpdateAt int64
// retrieves posts in the inclusive range: [SinceUpdateAt + LastPostId, UntilUpdateAt]
SincePostID string
SinceUpdateAt int64
UntilUpdateAt int64
}
func (o *Post) Etag() string {

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

@@ -78,6 +78,28 @@ func TranslationsPreInit(translationsDir string) error {
return initTranslationsWithDir(translationsDir)
}
// TranslationsPreInitFromFileBytes loads translations from a buffer -- useful if
// we need to initialize i18n from an embedded i18n file (e.g., from a CLI tool)
func TranslationsPreInitFromFileBytes(filename string, buf []byte) error {
if T != nil {
return nil
}
// Set T even if we fail to load the translations. Lots of shutdown handling code will
// segfault trying to handle the error, and the untranslated IDs are strictly better.
T = tfuncWithFallback(defaultLocale)
TDefault = tfuncWithFallback(defaultLocale)
locale := strings.Split(filename, ".")[0]
if !isSupportedLocale(locale) {
return fmt.Errorf("locale not supported: %s", locale)
}
locales[locale] = filename
return i18n.ParseTranslationFileBytes(filename, buf)
}
// InitTranslations set the defaults configured in the server and initialize
// the T function using the server default as fallback language
func InitTranslations(serverLocale, clientLocale string) error {

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

@@ -62,14 +62,20 @@
<h2>Conversation Summary</h2>
<div class="summary-list">
<ul>
<li><span class="bold">Channel:&nbsp;</span>{{.Props.ChannelName}}</li>
<li><span class="bold">Started:&nbsp;</span>{{.Props.Started}}</li>
<li><span class="bold">Ended:&nbsp;</span>{{.Props.Ended}}</li>
<li><span class="bold">Duration:&nbsp;</span>{{.Props.Duration}}</li>
<li><span class="bold">TeamId:</span>{{.Props.TeamId}}</li>
<li><span class="bold">TeamName:</span>{{.Props.TeamName}}</li>
<li><span class="bold">TeamDisplayName:</span>{{.Props.TeamDisplayName}}</li>
<li><span class="bold">ChannelId:</span>{{.Props.ChannelId}}</li>
<li><span class="bold">ChannelName:</span>{{.Props.ChannelName}}</li>
<li><span class="bold">ChannelDisplayName:</span>{{.Props.ChannelDisplayName}}</li>
<li><span class="bold">Started:</span>{{.Props.Started}}</li>
<li><span class="bold">Ended:</span>{{.Props.Ended}}</li>
<li><span class="bold">Duration:</span>{{.Props.Duration}}</li>
</ul>
</div>
<table class="participants">
<tr>
<th class="userid">UserId<br></th>
<th class="username">Username<br></th>
<th class="usertype">UserType<br></th>
<th class="email">Email</th>

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

@@ -1,11 +1,16 @@
{{define "globalrelay_compliance_export_message"}}
<li class="message">
<span class="post_id">{{.Props.PostId}}</span>
<span class="sent_time">{{.Props.SentTime}}</span>
<span class="username">@{{.Props.Username}}</span>
<span class="userid">{{.Props.UserId}}</span>
<span class="postusername">{{.Props.PostUsername}}</span>
<span class="usertype">{{.Props.UserType}}</span>
<span class="email">({{.Props.Email}}):</span>
<span class="message">{{.Props.Message}}</span>
<span class="previews_post">{{.Props.PreviewsPost}}</span>
<span class="email">({{.Props.Email}})</span>
<span class="message">{{.Props.Message}}</span>{{ if .Props.PreviewsPost }}
<span class="previews_post">{{.Props.PreviewsPost}}</span>{{ end }}{{ if .Props.UpdateType }}
<span class="update_type">{{.Props.UpdateType}}</span>
<span class="update_time">{{.Props.UpdateTime}}</span>
<span class="edited_new_msg_id">{{.Props.EditedNewMsgId}}</span>{{ end }}
</li>
{{end}}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше