From 7b2861de3a09cf00d00b0872cc537d54302c4bfa Mon Sep 17 00:00:00 2001 From: Jonathan Fritz Date: Mon, 12 Feb 2018 16:14:52 -0500 Subject: [PATCH 01/42] Removed unused fields from ChannelMemberHistory table, introduced new model.ChannelMemberHistoryResult object that includes those fields that are selected from a join on other tables --- app/channel_test.go | 20 ++++++------- model/ChannelMemberHistoryResult.go | 15 ++++++++++ model/channel_member_history.go | 4 --- .../sqlstore/channel_member_history_store.go | 8 +++--- store/sqlstore/upgrade.go | 2 ++ .../storetest/channel_member_history_store.go | 28 +++++++++---------- 6 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 model/ChannelMemberHistoryResult.go diff --git a/app/channel_test.go b/app/channel_test.go index d315fbae6f..e4a0e43207 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -116,14 +116,14 @@ func TestJoinDefaultChannelsTownSquare(t *testing.T) { // figure out the initial number of users in town square townSquareChannelId := store.Must(th.App.Srv.Store.Channel().GetByName(th.BasicTeam.Id, "town-square", true)).(*model.Channel).Id - initialNumTownSquareUsers := len(store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, townSquareChannelId)).([]*model.ChannelMemberHistory)) + initialNumTownSquareUsers := len(store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, townSquareChannelId)).([]*model.ChannelMemberHistoryResult)) // create a new user that joins the default channels user := th.CreateUser() th.App.JoinDefaultChannels(th.BasicTeam.Id, user, model.CHANNEL_USER_ROLE_ID, "") // there should be a ChannelMemberHistory record for the user - histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, townSquareChannelId)).([]*model.ChannelMemberHistory) + histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, townSquareChannelId)).([]*model.ChannelMemberHistoryResult) assert.Len(t, histories, initialNumTownSquareUsers+1) found := false @@ -142,14 +142,14 @@ func TestJoinDefaultChannelsOffTopic(t *testing.T) { // figure out the initial number of users in off-topic offTopicChannelId := store.Must(th.App.Srv.Store.Channel().GetByName(th.BasicTeam.Id, "off-topic", true)).(*model.Channel).Id - initialNumTownSquareUsers := len(store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, offTopicChannelId)).([]*model.ChannelMemberHistory)) + initialNumTownSquareUsers := len(store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, offTopicChannelId)).([]*model.ChannelMemberHistoryResult)) // create a new user that joins the default channels user := th.CreateUser() th.App.JoinDefaultChannels(th.BasicTeam.Id, user, model.CHANNEL_USER_ROLE_ID, "") // there should be a ChannelMemberHistory record for the user - histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, offTopicChannelId)).([]*model.ChannelMemberHistory) + histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, offTopicChannelId)).([]*model.ChannelMemberHistoryResult) assert.Len(t, histories, initialNumTownSquareUsers+1) found := false @@ -170,7 +170,7 @@ func TestCreateChannelPublic(t *testing.T) { publicChannel := th.createChannel(th.BasicTeam, model.CHANNEL_OPEN) // there should be a ChannelMemberHistory record for the user - histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, publicChannel.Id)).([]*model.ChannelMemberHistory) + histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, publicChannel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, histories, 1) assert.Equal(t, th.BasicUser.Id, histories[0].UserId) assert.Equal(t, publicChannel.Id, histories[0].ChannelId) @@ -184,7 +184,7 @@ func TestCreateChannelPrivate(t *testing.T) { privateChannel := th.createChannel(th.BasicTeam, model.CHANNEL_PRIVATE) // there should be a ChannelMemberHistory record for the user - histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, privateChannel.Id)).([]*model.ChannelMemberHistory) + histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, privateChannel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, histories, 1) assert.Equal(t, th.BasicUser.Id, histories[0].UserId) assert.Equal(t, privateChannel.Id, histories[0].ChannelId) @@ -221,7 +221,7 @@ func TestCreateGroupChannel(t *testing.T) { t.Fatal("Failed to create group channel. Error: " + err.Message) } else { // there should be a ChannelMemberHistory record for each user - histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)).([]*model.ChannelMemberHistory) + histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, histories, 3) channelMemberHistoryUserIds := make([]string, 0) @@ -253,7 +253,7 @@ func TestAddUserToChannel(t *testing.T) { } // there should be a ChannelMemberHistory record for the user - histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)).([]*model.ChannelMemberHistory) + histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, histories, 2) channelMemberHistoryUserIds := make([]string, 0) for _, history := range histories { @@ -269,7 +269,7 @@ func TestRemoveUserFromChannel(t *testing.T) { // a user creates a channel publicChannel := th.createChannel(th.BasicTeam, model.CHANNEL_OPEN) - histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, publicChannel.Id)).([]*model.ChannelMemberHistory) + histories := store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, publicChannel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, histories, 1) assert.Equal(t, th.BasicUser.Id, histories[0].UserId) assert.Equal(t, publicChannel.Id, histories[0].ChannelId) @@ -279,7 +279,7 @@ func TestRemoveUserFromChannel(t *testing.T) { if err := th.App.LeaveChannel(publicChannel.Id, th.BasicUser.Id); err != nil { t.Fatal("Failed to remove user from channel. Error: " + err.Message) } - histories = store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, publicChannel.Id)).([]*model.ChannelMemberHistory) + histories = store.Must(th.App.Srv.Store.ChannelMemberHistory().GetUsersInChannelDuring(model.GetMillis()-100, model.GetMillis()+100, publicChannel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, histories, 1) assert.Equal(t, th.BasicUser.Id, histories[0].UserId) assert.Equal(t, publicChannel.Id, histories[0].ChannelId) diff --git a/model/ChannelMemberHistoryResult.go b/model/ChannelMemberHistoryResult.go new file mode 100644 index 0000000000..ed3e796393 --- /dev/null +++ b/model/ChannelMemberHistoryResult.go @@ -0,0 +1,15 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +type ChannelMemberHistoryResult struct { + ChannelId string + UserId string + JoinTime int64 + LeaveTime *int64 + + // these two fields are never set in the database - when we SELECT, we join on Users to get them + UserEmail string `db:"Email"` + Username string +} diff --git a/model/channel_member_history.go b/model/channel_member_history.go index 47c59d54eb..55435c3206 100644 --- a/model/channel_member_history.go +++ b/model/channel_member_history.go @@ -8,8 +8,4 @@ type ChannelMemberHistory struct { UserId string JoinTime int64 LeaveTime *int64 - - // these two fields are never set in the database - when we SELECT, we join on Users to get them - UserEmail string `db:"Email"` - Username string } diff --git a/store/sqlstore/channel_member_history_store.go b/store/sqlstore/channel_member_history_store.go index 0b86aac282..6fc78b5141 100644 --- a/store/sqlstore/channel_member_history_store.go +++ b/store/sqlstore/channel_member_history_store.go @@ -106,7 +106,7 @@ func (s SqlChannelMemberHistoryStore) hasDataAtOrBefore(time int64) (bool, error } } -func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistory, error) { +func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistoryResult, error) { query := ` SELECT cmh.*, @@ -120,7 +120,7 @@ func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime ORDER BY cmh.JoinTime ASC` params := map[string]interface{}{"ChannelId": channelId, "StartTime": startTime, "EndTime": endTime} - var histories []*model.ChannelMemberHistory + var histories []*model.ChannelMemberHistoryResult if _, err := s.GetReplica().Select(&histories, query, params); err != nil { return nil, err } else { @@ -128,7 +128,7 @@ func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime } } -func (s SqlChannelMemberHistoryStore) getFromChannelMembersTable(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistory, error) { +func (s SqlChannelMemberHistoryStore) getFromChannelMembersTable(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistoryResult, error) { query := ` SELECT DISTINCT ch.ChannelId, @@ -140,7 +140,7 @@ func (s SqlChannelMemberHistoryStore) getFromChannelMembersTable(startTime int64 WHERE ch.ChannelId = :ChannelId` params := map[string]interface{}{"ChannelId": channelId} - var histories []*model.ChannelMemberHistory + var histories []*model.ChannelMemberHistoryResult if _, err := s.GetReplica().Select(&histories, query, params); err != nil { return nil, err } else { diff --git a/store/sqlstore/upgrade.go b/store/sqlstore/upgrade.go index 56fdf9d6c8..09f2c9160a 100644 --- a/store/sqlstore/upgrade.go +++ b/store/sqlstore/upgrade.go @@ -346,6 +346,8 @@ func UpgradeDatabaseToVersion47(sqlStore SqlStore) { if shouldPerformUpgrade(sqlStore, VERSION_4_6_0, VERSION_4_7_0) { sqlStore.AlterColumnTypeIfExists("Users", "Position", "varchar(128)", "varchar(128)") sqlStore.AlterColumnTypeIfExists("OAuthAuthData", "State", "varchar(1024)", "varchar(1024)") + sqlStore.RemoveColumnIfExists("ChannelMemberHistory", "Email") + sqlStore.RemoveColumnIfExists("ChannelMemberHistory", "Username") saveSchemaVersion(sqlStore, VERSION_4_7_0) } } diff --git a/store/storetest/channel_member_history_store.go b/store/storetest/channel_member_history_store.go index fa2e7a8faf..b79e84fd88 100644 --- a/store/storetest/channel_member_history_store.go +++ b/store/storetest/channel_member_history_store.go @@ -102,11 +102,11 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { store.Must(ss.ChannelMemberHistory().LogJoinEvent(user.Id, channel.Id, joinTime)) // case 1: user joins and leaves the channel before the export period begins - channelMembers := store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers := store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 0) // case 2: user joins the channel after the export period begins, but has not yet left the channel when the export period ends - channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -116,7 +116,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { 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 = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -129,7 +129,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { store.Must(ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, 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 = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -139,7 +139,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { 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 = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -149,7 +149,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { assert.Equal(t, leaveTime, *channelMembers[0].LeaveTime) // case 6: user has joined and left the channel long before the export period begins - channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 0) } @@ -195,7 +195,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { // 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 := store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers := store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-500, joinTime-100, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -205,7 +205,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { 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 = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -215,7 +215,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { 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 = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, joinTime+500, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -225,7 +225,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { 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 = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+100, leaveTime-100, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -235,7 +235,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { 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 = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime-100, leaveTime+100, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -245,7 +245,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { assert.Equal(t, leaveTime+100, *channelMembers[0].LeaveTime) // case 6: user has joined and left the channel long before the export period begins - channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(leaveTime+100, leaveTime+200, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) @@ -290,7 +290,7 @@ func testPermanentDeleteBatch(t *testing.T, ss store.Store) { store.Must(ss.ChannelMemberHistory().LogJoinEvent(user2.Id, channel.Id, joinTime)) // in between the join time and the leave time, both users were members of the channel - channelMembers := store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+10, leaveTime-10, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers := store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+10, leaveTime-10, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 2) // the permanent delete should delete at least one record @@ -298,7 +298,7 @@ func testPermanentDeleteBatch(t *testing.T, ss store.Store) { assert.NotEqual(t, int64(0), rowsDeleted) // after the delete, there should be one less member in the channel - channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+10, leaveTime-10, channel.Id)).([]*model.ChannelMemberHistory) + channelMembers = store.Must(ss.ChannelMemberHistory().GetUsersInChannelDuring(joinTime+10, leaveTime-10, channel.Id)).([]*model.ChannelMemberHistoryResult) assert.Len(t, channelMembers, 1) assert.Equal(t, user2.Id, channelMembers[0].UserId) } From 445a356d0dc958a8fb72fd9835816302260595a2 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Mon, 12 Feb 2018 23:08:38 +0000 Subject: [PATCH 02/42] translations PR 20180212 --- i18n/de.json | 22 ++++------ i18n/en.json | 4 +- i18n/es.json | 20 ++++----- i18n/fr.json | 68 ++++++++++++++--------------- i18n/it.json | 20 ++++----- i18n/ja.json | 20 ++++----- i18n/ko.json | 22 ++++------ i18n/nl.json | 114 +++++++++++++++++++++++------------------------- i18n/pl.json | 20 ++++----- i18n/pt-BR.json | 20 ++++----- i18n/ru.json | 36 +++++++-------- i18n/tr.json | 20 ++++----- i18n/zh-CN.json | 20 ++++----- i18n/zh-TW.json | 30 ++++++------- 14 files changed, 192 insertions(+), 244 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index b506e70184..4f9ac581fe 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -2184,7 +2184,7 @@ }, { "id": "api.team.add_user_to_team.added", - "translation": "%v wurde von %v zum Team hinzugefügt" + "translation": "%v wurde von %v zum Team hinzugefügt." }, { "id": "api.team.add_user_to_team.missing_parameter.app_error", @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "Der Link ist abgelaufen" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "Der Einladungslink scheint nicht gültig zu sein" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "Diese URL ist nicht verfügbar. Bitte wähle eine andere." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "Der Registrierungslink scheint nicht gültig zu sein" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "Ungültiger Teamname" @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "Ungültige Thumbnauilbreite in Dateieinstellungen. Muss eine positive Zahl sein." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Ungültige Diensteinstellungen für Gruppierung von ungelesenen Kanälen. Muss 'disabled', 'default_on' oder default_off' sein." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "Ungültiger Bild-Proxy-Typ für Diensteinstellungen." @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "Registrieren" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "Der Registrierungslink scheint nicht gültig zu sein" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "Der Registrierungslink ist abgelaufen" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "Der Registrierungslink ist abgelaufen" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "Der Registrierungslink scheint nicht gültig zu sein" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "Dieser Teamtyp erlaubt keine offnen Einladungen" diff --git a/i18n/en.json b/i18n/en.json index 4365a44fb6..41fd9d982b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2703,11 +2703,11 @@ "translation": "The signup link has expired" }, { - "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "The signup link does not appear to be valid" }, { - "id": "api.user.create_user.signup_link_invalid.app_error", + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", "translation": "The signup link does not appear to be valid" }, { diff --git a/i18n/es.json b/i18n/es.json index c96674885d..b3d472b9f5 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "El enlace de registro ha expirado" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "El enlace de registro parece ser inválido" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "Este URL no está disponible. Por favor, prueba con otra." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "El enlace de registro parece ser inválido" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "Nombre del equipo inválido" @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "El ancho para la imagen de miniatura es inválido en la configuración de archivos. Debe ser un número positivo." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Ajuste invalido para el agrupamiento de canales no leídos en la configuración de servicio. Debe ser 'disabled', 'default_on' o 'default_off'." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "Tipo de proxy para las imágenes no válido en la configuración del servicio." @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "Registrar" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "El enlace de registro parece ser inválido" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "El enlace de registro ha expirado" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "El enlace de registro ha expirado" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "El enlace de registro parece ser inválido" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "El tipo de equipo no permite realizar invitaciones" diff --git a/i18n/fr.json b/i18n/fr.json index f2c20c29f0..66a414e648 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -153,7 +153,7 @@ }, { "id": "api.channel.add_member.added", - "translation": "%v a été ajouté au canal par %v" + "translation": "%v a été ajouté au canal par %v." }, { "id": "api.channel.add_member.find_channel.app_error", @@ -201,11 +201,11 @@ }, { "id": "api.channel.change_channel_privacy.private_to_public", - "translation": "This channel has been converted to a Public Channel and can be joined by any team member." + "translation": "Ce canal a été converti en canal public et peut être rejoint par tout membre de l'équipe." }, { "id": "api.channel.change_channel_privacy.public_to_private", - "translation": "This channel has been converted to a Private Channel." + "translation": "Ce canal a été converti en canal privé." }, { "id": "api.channel.create_channel.direct_channel.app_error", @@ -2184,7 +2184,7 @@ }, { "id": "api.team.add_user_to_team.added", - "translation": "%v a été ajouté à l'équipe par %v" + "translation": "%v a été ajouté à l'équipe par %v." }, { "id": "api.team.add_user_to_team.missing_parameter.app_error", @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "Le lien d'inscription a expiré." }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "Le lien d'inscription semble ne pas être valide." - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "Cette URL n'est pas disponible. Veuillez en essayer une autre." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "Le lien d'enregistrement n'est pas valide." }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "Nom d'équipe incorrect" @@ -2956,7 +2956,7 @@ }, { "id": "api.user.upload_profile_user.decode_config.app_error", - "translation": "Impossible de sauvegarder l'image de profile. Le fichier ne semble pas être un fichier d'image valide." + "translation": "Impossible de sauvegarder l'image de profil. Le fichier ne semble pas être un fichier d'image valide." }, { "id": "api.user.upload_profile_user.encode.app_error", @@ -3360,7 +3360,7 @@ }, { "id": "app.import.validate_post_import_data.create_at_zero.error", - "translation": "La propriété de message CreateAt ne doit pas être 0 si ce champ est défini." + "translation": "La propriété de message CreateAt ne doit pas être 0." }, { "id": "app.import.validate_post_import_data.message_length.error", @@ -3380,51 +3380,51 @@ }, { "id": "app.import.validate_reaction_import_data.create_at_before_parent.error", - "translation": "Reaction CreateAt property must be greater than the parent post CreateAt." + "translation": "La propriété de réponse CreateAt doit être plus grande que la valeur de la propriété CreateAt parente." }, { "id": "app.import.validate_reaction_import_data.create_at_missing.error", - "translation": "La propriété requise pour un message est manquante : create_at." + "translation": "La propriété requise de réaction est manquante : create_at." }, { "id": "app.import.validate_reaction_import_data.create_at_zero.error", - "translation": "La propriété de message CreateAt ne doit pas être 0 si ce champ est défini." + "translation": "La propriété de réaction CreateAt ne doit pas être 0." }, { "id": "app.import.validate_reaction_import_data.emoji_name_length.error", - "translation": "La propriété Message du message est plus longue que la longueur maximale autorisée." + "translation": "La propriété de réaction EmojiName est plus longue que la longueur maximale autorisée." }, { "id": "app.import.validate_reaction_import_data.emoji_name_missing.error", - "translation": "La propriété requise du message est manquante : User." + "translation": "La propriété requise de réaction est manquante : EmojiName." }, { "id": "app.import.validate_reaction_import_data.user_missing.error", - "translation": "La propriété requise du message est manquante : User." + "translation": "La propriété requise de réaction est manquante : User." }, { "id": "app.import.validate_reply_import_data.create_at_before_parent.error", - "translation": "Reply CreateAt property must be greater than the parent post CreateAt." + "translation": "La propriété de réponse CreateAt doit être plus grande que la valeur de la propriété CreateAt parente." }, { "id": "app.import.validate_reply_import_data.create_at_missing.error", - "translation": "La propriété requise pour un message est manquante : create_at." + "translation": "La propriété requise de réponse est manquante : create_at." }, { "id": "app.import.validate_reply_import_data.create_at_zero.error", - "translation": "La propriété de message CreateAt ne doit pas être 0 si ce champ est défini." + "translation": "La propriété de message CreateAt ne doit pas être 0." }, { "id": "app.import.validate_reply_import_data.message_length.error", - "translation": "La propriété Message du message est plus longue que la longueur maximale autorisée." + "translation": "La propriété de réponse Message est plus longue que la longueur maximale autorisée." }, { "id": "app.import.validate_reply_import_data.message_missing.error", - "translation": "La propriété requise du message est manquante : Message." + "translation": "La propriété requise de réponse est manquante : Message." }, { "id": "app.import.validate_reply_import_data.user_missing.error", - "translation": "La propriété requise du message est manquante : User." + "translation": "La propriété requise de réponse est manquante : User." }, { "id": "app.import.validate_team_import_data.allowed_domains_length.error", @@ -3564,7 +3564,7 @@ }, { "id": "app.import.validate_user_import_data.profile_image.error", - "translation": "Invalid profile image." + "translation": "Image de profil invalide." }, { "id": "app.import.validate_user_import_data.roles_invalid.error", @@ -4644,7 +4644,7 @@ }, { "id": "model.config.is_valid.atmos_camo_image_proxy_options.app_error", - "translation": "Invalid atmos/camo image proxy options for service settings. Must be set to your shared key." + "translation": "Les paramètres d'options du proxy d'image atmos/camo sont invalides. Votre clé partagée doit être définie comme paramètre." }, { "id": "model.config.is_valid.cluster_email_batching.app_error", @@ -4758,9 +4758,13 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "Largeur des miniatures invalide dans les paramètres de fichiers. Doit être un entier positif." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Le paramètre de groupement de canaux non lus est invalide. Doit être défini sur « disabled », « default_on » ou « default_off »." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", - "translation": "Invalid image proxy type for service settings." + "translation": "Paramètre de type de proxy d'image invalide." }, { "id": "model.config.is_valid.ldap_basedn", @@ -5404,7 +5408,7 @@ }, { "id": "model.user.is_valid.position.app_error", - "translation": "Rôle invalide : ne doit pas faire plus de 35 caractères." + "translation": "Rôle invalide : ne doit pas faire plus de 128 caractères." }, { "id": "model.user.is_valid.pwd.app_error", @@ -6848,7 +6852,7 @@ }, { "id": "store.sql_user_access_token.get_all.app_error", - "translation": "Impossible de récupérer le jeton d'accès personnel" + "translation": "Impossible de récupérer tous les jetons d'accès personnel" }, { "id": "store.sql_user_access_token.get_by_token.app_error", @@ -6864,7 +6868,7 @@ }, { "id": "store.sql_user_access_token.search.app_error", - "translation": "Nous avons rencontré une erreur lors de la recherche du jeton d'accès" + "translation": "Nous avons rencontré une erreur lors de la recherche des jetons d'accès personnel" }, { "id": "store.sql_webhooks.analytics_incoming_count.app_error", @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "Inscription" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "Le lien d'inscription ne semble pas être valide" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "Le lien d'inscription a expiré" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "Le lien d'inscription a expiré" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "Le lien d'inscription ne semble pas être valide" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "Le type d'équipe ne permet pas les invitations ouvertes" diff --git a/i18n/it.json b/i18n/it.json index a58dfa15a8..c2f62ae5d0 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "Il collegamento per l'iscrizione è scaduto" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "Il collegamento per l'iscrizione non sembra essere valido" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "L'URL non è disponibile. Provane un altro." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "Il collegamento per l'iscrizione non sembra essere valido" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "Nome gruppo non valido" @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "Larghezza antemprima non valida per le impostazioni file. Deve essere un numero positivo." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Canali non letti del gruppo non validi per le impostazioni del servizio. Dev'essere 'disabled', 'default_on' oppure 'default_off'." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "Tipo proxy immagine non valido nelle impostazioni del servizio." @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "Iscrizione" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "Il collegamento per l'iscrizione non sembra essere valido" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "Il collegamento per l'iscrizione è scaduto" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "Il collegamento per l'iscrizione è scaduto" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "Il collegamento per l'iscrizione non sembra essere valido" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "La tipologia del gruppo non ammette inviti aperti" diff --git a/i18n/ja.json b/i18n/ja.json index 3017f94dca..c525f471bc 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "利用登録リンクは有効期限が切れています" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "利用登録リンクが不正です" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "このURLは利用できません。他のURLを試してみてください。" @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "利用登録リンクが不正です" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "不正なチーム名です" @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "ファイル設定のサムネイルの幅が不正です。ゼロ以上の数を指定してください。" }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "未読チャンネルのグループ化設定が不正です。'disabled', 'default_on', 'default_off'のいずれかでなくてはなりません。" + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "画像プロキシタイプの設定が不正です。" @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "利用登録" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "利用登録リンクが不正です" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "利用登録リンクの期限が切れています" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "利用登録リンクの期限が切れています" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "利用登録リンクが不正です" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "このチームは誰でも招待できるような形式ではありません" diff --git a/i18n/ko.json b/i18n/ko.json index bdd2935a44..68fb33734c 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "가입 링크가 만료되었습니다." }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "유효하지 않은 가입 링크입니다." - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "유효하지 않는 URL 입니다. 변경 후 다시 시도해주세요." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "유효하지 않은 가입 링크입니다." }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "올바르지 않은 팀 이름" @@ -3136,7 +3136,7 @@ }, { "id": "app.channel.post_update_channel_purpose_message.post.error", - "translation": "Failed to post channel purpose message" + "translation": "들어옴/나감 메시지를 등록하는 데 실패함" }, { "id": "app.channel.post_update_channel_purpose_message.removed", @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "파일 세팅에 대한 잘못된 썸네일 너비값입니다. 0보다 큰 값이여야 합니다." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Invalid group unread channels for service settings. Must be 'disabled', 'default_on', or 'default_off'." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "Invalid image proxy type for service settings." @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "가입" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "유효하지 않은 가입 링크입니다." - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "가입 링크가 만료되었습니다." @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "가입 링크가 만료되었습니다." }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "유효하지 않은 가입 링크입니다." - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "The team type doesn't allow open invites" diff --git a/i18n/nl.json b/i18n/nl.json index 2e0f54ee41..2e02433e4c 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -49,7 +49,7 @@ }, { "id": "api.admin.add_certificate.no_file.app_error", - "translation": "Geen bestand in 'certificate' veld in aanvraag" + "translation": "Geen bestand gevonden in 'certificate' veld in verzoek" }, { "id": "api.admin.add_certificate.open.app_error", @@ -57,15 +57,15 @@ }, { "id": "api.admin.add_certificate.saving.app_error", - "translation": "Kon het certificaat-bestand niet opslaan" + "translation": "Kon het certificaat-bestand niet opslaan." }, { "id": "api.admin.file_read_error", - "translation": "Fout bij lezen van logbestand" + "translation": "Fout bij lezen van logbestand." }, { "id": "api.admin.get_brand_image.not_available.app_error", - "translation": "Personaliseren is niet ondersteund, of niet geconfigureerd op deze server" + "translation": "Personalisatie is niet ondersteund of niet geconfigureerd op deze server." }, { "id": "api.admin.get_brand_image.storage.app_error", @@ -73,15 +73,15 @@ }, { "id": "api.admin.init.debug", - "translation": "Initialisatie van de admin API routes" + "translation": "Initialisatie van de admin API routes." }, { "id": "api.admin.recycle_db_end.warn", - "translation": "Klaar met herstarten van de databaseverbinding" + "translation": "Klaar met herstarten van de databaseverbinding." }, { "id": "api.admin.recycle_db_start.warn", - "translation": "Proberen om de databaseverbinding te herstarten" + "translation": "Proberen om de databaseverbinding te herstarten." }, { "id": "api.admin.remove_certificate.delete.app_error", @@ -89,7 +89,7 @@ }, { "id": "api.admin.saml.metadata.app_error", - "translation": "Er is een fout opgetreden tijdens het opstellen van Service Provider Metadata." + "translation": "Er is een fout opgetreden tijdens het opzetten van Service Provider Metadata." }, { "id": "api.admin.test_email.body", @@ -153,7 +153,7 @@ }, { "id": "api.channel.add_member.added", - "translation": "%v is door %v aan het kanaal toegevoegd" + "translation": "%v is door %v aan het kanaal toegevoegd." }, { "id": "api.channel.add_member.find_channel.app_error", @@ -185,11 +185,11 @@ }, { "id": "api.channel.can_manage_channel.private_restricted_system_admin.app_error", - "translation": "Alleen systeembeheerders mogen publieke kanalen beheren en aanmaken." + "translation": "Alleen systeembeheerders mogen privékanalen beheren en aanmaken." }, { "id": "api.channel.can_manage_channel.private_restricted_team_admin.app_error", - "translation": "Alleen team- en systeembeheerders mogen publieke kanalen beheren en aanmaken." + "translation": "Alleen team- en systeembeheerders mogen privékanalen beheren en aanmaken." }, { "id": "api.channel.can_manage_channel.public_restricted_system_admin.app_error", @@ -201,11 +201,11 @@ }, { "id": "api.channel.change_channel_privacy.private_to_public", - "translation": "This channel has been converted to a Public Channel and can be joined by any team member." + "translation": "Dit kanaal is omgezet naar een publiek kanaal en is open voor ieder teamlid." }, { "id": "api.channel.change_channel_privacy.public_to_private", - "translation": "This channel has been converted to a Private Channel." + "translation": "This kanaal is omgezet naar een privékanaal." }, { "id": "api.channel.create_channel.direct_channel.app_error", @@ -229,15 +229,15 @@ }, { "id": "api.channel.create_direct_channel.invalid_user.app_error", - "translation": "Invalid user ID for direct channel creation" + "translation": "Onjuiste gebruikers ID voor het aanmaken van een direct kanaal." }, { "id": "api.channel.create_group.bad_size.app_error", - "translation": "Group message channels must contain at least 3 and no more than 8 users" + "translation": "Groep bericht kanalen moeten tenminste 3 en niet meer dan 8 gebruikers bevatten" }, { "id": "api.channel.create_group.bad_user.app_error", - "translation": "One of the provided users does not exist" + "translation": "Een van de opgegeven gebruikers bestaat niet" }, { "id": "api.channel.delete_channel.archived", @@ -305,7 +305,7 @@ }, { "id": "api.channel.join_channel.post_and_forget", - "translation": "%v is het kanaal binnengekomen." + "translation": "%v is nu lid van het kanaal." }, { "id": "api.channel.leave.default.app_error", @@ -317,7 +317,7 @@ }, { "id": "api.channel.leave.last_member.app_error", - "translation": "U bent het laatste lid, probeer de privégroep te verwijderen in plaats van die te verlaten." + "translation": "U bent het laatste lid, probeer de privékanaalte verwijderen in plaats van die te verlaten." }, { "id": "api.channel.leave.left", @@ -329,7 +329,7 @@ }, { "id": "api.channel.post_update_channel_displayname_message_and_forget.retrieve_user.error", - "translation": "Kon de gebruiker niet ophalen tijdens het bewaren van de nieuwe kanaalkoptekst %v" + "translation": "Kon de gebruiker niet ophalen tijdens het bijwerken van het kanaal weergavenaam veld" }, { "id": "api.channel.post_update_channel_displayname_message_and_forget.updated_from", @@ -337,7 +337,7 @@ }, { "id": "api.channel.post_update_channel_header_message_and_forget.post.error", - "translation": "Failed to post update channel header message" + "translation": "Kanaalkoptekst bijwerken mislukt" }, { "id": "api.channel.post_update_channel_header_message_and_forget.removed", @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "De aanmeld link is verlopen" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "De aanmeld link is niet geldig" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "Deze URL is niet beschikbaar. Probeer een andere." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "De aanmeld link is niet geldig" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "Ongeldige team naam" @@ -3136,7 +3136,7 @@ }, { "id": "app.channel.post_update_channel_purpose_message.post.error", - "translation": "Failed to post channel purpose message" + "translation": "Plaatsen van bericht over binnenkomen/verlaten %v is mislukt" }, { "id": "app.channel.post_update_channel_purpose_message.removed", @@ -3204,7 +3204,7 @@ }, { "id": "app.import.import_line.null_channel.error", - "translation": "Import data line has type \"channel\" but the channel object is null." + "translation": "Geimporteerde lijn met type \"user\", maar het \"user\" object is null." }, { "id": "app.import.import_line.null_direct_channel.error", @@ -3216,15 +3216,15 @@ }, { "id": "app.import.import_line.null_post.error", - "translation": "Import data line has type \"post\" but the post object is null." + "translation": "Geimporteerde lijn met type \"user\", maar het \"user\" object is null." }, { "id": "app.import.import_line.null_team.error", - "translation": "Import data line has type \"team\" but the team object is null." + "translation": "Geimporteerde lijn met type \"user\", maar het \"user\" object is null." }, { "id": "app.import.import_line.null_user.error", - "translation": "Import data line has type \"user\" but the user object is null." + "translation": "Geimporteerde lijn met type \"user\", maar het \"user\" object is null." }, { "id": "app.import.import_line.unknown_line_type.error", @@ -3252,7 +3252,7 @@ }, { "id": "app.import.validate_channel_import_data.create_at_zero.error", - "translation": "Channel create_at must not be zero if provided." + "translation": "Kanaaleigenschap create_at mag niet 0 zijn indien opgegeven." }, { "id": "app.import.validate_channel_import_data.display_name_length.error", @@ -3276,7 +3276,7 @@ }, { "id": "app.import.validate_channel_import_data.name_missing.error", - "translation": "Missing required channel property: name" + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_channel_import_data.purpose_length.error", @@ -3292,7 +3292,7 @@ }, { "id": "app.import.validate_channel_import_data.type_missing.error", - "translation": "Missing required channel property: type." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_direct_channel_import_data.header_length.error", @@ -3352,7 +3352,7 @@ }, { "id": "app.import.validate_post_import_data.channel_missing.error", - "translation": "Missing required Post property: Channel." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_post_import_data.create_at_missing.error", @@ -3368,19 +3368,19 @@ }, { "id": "app.import.validate_post_import_data.message_missing.error", - "translation": "Missing required Post property: Message." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_post_import_data.team_missing.error", - "translation": "Missing required Post property: Team." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_post_import_data.user_missing.error", - "translation": "Missing required Post property: User." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_reaction_import_data.create_at_before_parent.error", - "translation": "Reaction CreateAt property must be greater than the parent post CreateAt." + "translation": "Reactie CreateAt eigenschap moet groter zijn dan het hoofdbericht CreateAt" }, { "id": "app.import.validate_reaction_import_data.create_at_missing.error", @@ -3400,11 +3400,11 @@ }, { "id": "app.import.validate_reaction_import_data.user_missing.error", - "translation": "Missing required Reaction property: User." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_reply_import_data.create_at_before_parent.error", - "translation": "Reply CreateAt property must be greater than the parent post CreateAt." + "translation": "Reactie CreateAt eigenschap moet groter zijn dan het hoofdbericht CreateAt" }, { "id": "app.import.validate_reply_import_data.create_at_missing.error", @@ -3420,11 +3420,11 @@ }, { "id": "app.import.validate_reply_import_data.message_missing.error", - "translation": "Missing required Reply property: Message." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_reply_import_data.user_missing.error", - "translation": "Missing required Reply property: User." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_team_import_data.allowed_domains_length.error", @@ -3432,7 +3432,7 @@ }, { "id": "app.import.validate_team_import_data.create_at_zero.error", - "translation": "Team create_at must not be zero if provided." + "translation": "Kanaaleigenschap create_at mag niet 0 zijn indien opgegeven." }, { "id": "app.import.validate_team_import_data.description_length.error", @@ -3444,7 +3444,7 @@ }, { "id": "app.import.validate_team_import_data.display_name_missing.error", - "translation": "Missing required team property: display_name." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_team_import_data.name_characters.error", @@ -3456,7 +3456,7 @@ }, { "id": "app.import.validate_team_import_data.name_missing.error", - "translation": "Missing required team property: name." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_team_import_data.name_reserved.error", @@ -3468,7 +3468,7 @@ }, { "id": "app.import.validate_team_import_data.type_missing.error", - "translation": "Missing required team property: type." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_user_channels_import_data.channel_name_missing.error", @@ -3508,7 +3508,7 @@ }, { "id": "app.import.validate_user_import_data.email_missing.error", - "translation": "Missing required user property: email." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_user_import_data.first_name_length.error", @@ -3576,7 +3576,7 @@ }, { "id": "app.import.validate_user_import_data.username_missing.error", - "translation": "Missing require user property: username." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_user_teams_import_data.invalid_roles.error", @@ -4028,7 +4028,7 @@ }, { "id": "ent.elasticsearch.test_config.connect_failed", - "translation": "Connecting to Elasticsearch server failed." + "translation": "Connectie naar Elasticsearch server is mislukt." }, { "id": "ent.elasticsearch.test_config.indexing_disabled.error", @@ -4672,7 +4672,7 @@ }, { "id": "model.config.is_valid.elastic_search.connection_url.app_error", - "translation": "Elastic Search ConnectionUrl setting must be provided when Elastic Search indexing is enabled." + "translation": "Elastic Search Username instelling moet ingevuld zijn wanneer Elastic Search indexing is ingeschakeld." }, { "id": "model.config.is_valid.elastic_search.enable_searching.app_error", @@ -4684,7 +4684,7 @@ }, { "id": "model.config.is_valid.elastic_search.password.app_error", - "translation": "Elastic Search Password setting must be provided when Elastic Search indexing is enabled." + "translation": "Elastic Search Username instelling moet ingevuld zijn wanneer Elastic Search indexing is ingeschakeld." }, { "id": "model.config.is_valid.elastic_search.posts_aggregator_job_start_time.app_error", @@ -4696,7 +4696,7 @@ }, { "id": "model.config.is_valid.elastic_search.username.app_error", - "translation": "Elastic Search Username setting must be provided when Elastic Search indexing is enabled." + "translation": "Elastic Search Username instelling moet ingevuld zijn wanneer Elastic Search indexing is ingeschakeld." }, { "id": "model.config.is_valid.email_batching_buffer_size.app_error", @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "Ongeldige voorvertonings breedte bij bestands instellingen. Moet een getal groter dan 0 zijn." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Invalid group unread channels for service settings. Must be 'disabled', 'default_on', or 'default_off'." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "Invalid image proxy type for service settings." @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "Aanmelden" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "De aanmeld link is niet geldig" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "De aanmeld link is verlopen" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "De aanmeld link is verlopen" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "De aanmeld link is niet geldig" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "Het team type staat open uitnodiging niet toe" diff --git a/i18n/pl.json b/i18n/pl.json index ccf9fed39a..d149f38b08 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "Odnośnik zapisu jest przeterminowany" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "Odnośnik zapisu nie wydaje się być poprawny" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "Ten adres URL jest niedostępny. Proszę wybrać inny." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "Link rejestracyjny wydaje się być niepoprawny" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "Niepoprawna nazwa zespołu" @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "Nieprawidłowa szerokość miniaturki dla ustawień plików. Musi być liczbą większą od 0." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Invalid group unread channels for service settings. Must be 'disabled', 'default_on', or 'default_off'." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "Invalid image proxy type for service settings." @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "Rejestracja" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "Link do rejestracji jest niepoprawny" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "Link do rejestracji wygasł" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "Link do rejestracji wygasł" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "Link do rejestracji jest niepoprawny" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "The team type doesn't allow open invites" diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index b07cb452e0..3ec45c2eb7 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "O link de inscrição expirou" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "O link de inscrição não parece ser válido" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "Esta URL não está disponível. Por favor, tente outra." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "O link de inscrição não parece ser válido" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "Inválido nome de equipe" @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "Definição da largura do thumbnail inválida. Deve ser um número positivo." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Grupo de canais não lidos inválido nas configurações do serviço. Deve ser 'disabled', 'default_on', ou 'default_off'." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "Tipo de proxy de imagem inválido nas configurações de serviços." @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "Inscrever-se" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "O link de inscrição não parece ser válido" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "O link de inscrição expirou" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "O link de inscrição expirou" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "O link de inscrição não parece ser válido" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "O tipo da equipe não permite convites abertos" diff --git a/i18n/ru.json b/i18n/ru.json index b1e37d1917..cb4d26d451 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -201,11 +201,11 @@ }, { "id": "api.channel.change_channel_privacy.private_to_public", - "translation": "This channel has been converted to a Public Channel and can be joined by any team member." + "translation": "Канал преобразован в публичный и к нему может присоединиться любой участник." }, { "id": "api.channel.change_channel_privacy.public_to_private", - "translation": "This channel has been converted to a Private Channel." + "translation": "Канал преобразован в приватный." }, { "id": "api.channel.create_channel.direct_channel.app_error", @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "Ссылка на регистрацию устарела" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "Неправильная ссылка на регистрацию" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "Этот URL-адрес недоступен. Пожалуйста, попробуйте другой." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "Ссылка для регистрации, похоже, неверна." }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "Неверное имя команды" @@ -3248,7 +3248,7 @@ }, { "id": "app.import.import_user_channels.save_preferences.error", - "translation": "Error importing user channel memberships. Failed to save preferences." + "translation": "Ошибка импорта участия пользователя в каналах. Не удалось сохранить настройки." }, { "id": "app.import.validate_channel_import_data.create_at_zero.error", @@ -3296,7 +3296,7 @@ }, { "id": "app.import.validate_direct_channel_import_data.header_length.error", - "translation": "Direct channel header is too long" + "translation": "Заголовок личного канала слишком длинный." }, { "id": "app.import.validate_direct_channel_import_data.members_required.error", @@ -3304,11 +3304,11 @@ }, { "id": "app.import.validate_direct_channel_import_data.members_too_few.error", - "translation": "Direct channel members list contains too few items" + "translation": "В списке пользователей личного канала слишком мало элементов." }, { "id": "app.import.validate_direct_channel_import_data.members_too_many.error", - "translation": "Direct channel members list contains too many items" + "translation": "В списке пользователей личного канала слишком много элементов." }, { "id": "app.import.validate_direct_channel_import_data.unknown_favoriter.error", @@ -3320,11 +3320,11 @@ }, { "id": "app.import.validate_direct_post_import_data.channel_members_too_few.error", - "translation": "Direct post channel members list contains too few items" + "translation": "В списке пользователей личного канала слишком мало элементов." }, { "id": "app.import.validate_direct_post_import_data.channel_members_too_many.error", - "translation": "Direct post channel members list contains too many items" + "translation": "В списке пользователей личного канала слишком много элементов." }, { "id": "app.import.validate_direct_post_import_data.create_at_missing.error", @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "Неверная ширина миниатюры в настройках файлов. Число должно быть положительным." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Invalid group unread channels for service settings. Must be 'disabled', 'default_on', or 'default_off'." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "Invalid image proxy type for service settings." @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "Регистрация" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "Ссылка для регистрации, похоже, недействительна" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "Ссылка для регистрации устарела" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "Ссылка для регистрации устарела" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "Ссылка для регистрации, похоже, недействительна" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "Тип команды не позволяет открытые приглашения" diff --git a/i18n/tr.json b/i18n/tr.json index b95c9b5428..ae782ddb45 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "Hesap açma bağlantısının süresi geçmiş" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "Hesap açma bağlantısı geçersiz görünüyor" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "Adres kullanılamıyor. Lütfen yeniden deneyin." @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "Hesap açma bağlantısı geçersiz görünüyor" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "Takım adı geçersiz" @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "Küçük görsel genişliği dosya ayarı geçersiz. Sıfır ya da pozitif bir sayı olmalı." }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Hizmet ayarları okunmamış kanallar gruplansın geçersiz. 'disabled', 'default_on' ya da 'default_off' olmalıdır." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "Hizmet ayarlarında görsel vekil sunucu türü geçersiz." @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "Hesap Açın" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "Hesap açma bağlantısı geçersiz görünüyor" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "Hesap açma bağlantısının süresi geçmiş" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "Hesap açma bağlantısının süresi geçmiş" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "Hesap açma bağlantısı geçersiz görünüyor" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "Takım türü açık çağrılara izin vermiyor" diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index fb484b50d7..9186f936e9 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "注册链接已过期" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "注册链接无效" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "该URL无效。请尝试其他。" @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "注册链接无效" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "无效团队名称" @@ -4758,6 +4758,10 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "文件设置时缩略图宽度无效。必须是正数。" }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "Invalid group unread channels for service settings. Must be 'disabled', 'default_on', or 'default_off'." + }, { "id": "model.config.is_valid.image_proxy_type.app_error", "translation": "无效的图片代理类型服务设定。" @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "注册" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "注册链接无效" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "注册链接已过期" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "注册链接已过期" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "注册链接无效" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "团队类型不允许公开邀请" diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index a7387d88f7..e31a359418 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -2202,10 +2202,6 @@ "id": "api.team.create_team_from_signup.expired_link.app_error", "translation": "註冊連結已過期" }, - { - "id": "api.team.create_team_from_signup.invalid_link.app_error", - "translation": "此註冊連結不是有效連結" - }, { "id": "api.team.create_team_from_signup.unavailable.app_error", "translation": "這個網址不存在。請嘗試其他的。" @@ -2710,6 +2706,10 @@ "id": "api.user.create_user.signup_link_invalid.app_error", "translation": "此註冊連結不是有效連結" }, + { + "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", + "translation": "The signup link does not appear to be valid" + }, { "id": "api.user.create_user.team_name.app_error", "translation": "無效的團隊名稱" @@ -3564,7 +3564,7 @@ }, { "id": "app.import.validate_user_import_data.profile_image.error", - "translation": "Invalid profile image." + "translation": "無效的個人頭像。" }, { "id": "app.import.validate_user_import_data.roles_invalid.error", @@ -4644,7 +4644,7 @@ }, { "id": "model.config.is_valid.atmos_camo_image_proxy_options.app_error", - "translation": "Invalid atmos/camo image proxy options for service settings. Must be set to your shared key." + "translation": "服務設定中的 atmos/camo 圖片代理無效。必須設定為分享金鑰。" }, { "id": "model.config.is_valid.cluster_email_batching.app_error", @@ -4758,9 +4758,13 @@ "id": "model.config.is_valid.file_thumb_width.app_error", "translation": "檔案設定中縮圖寬度無效。必須為正數。" }, + { + "id": "model.config.is_valid.group_unread_channels.app_error", + "translation": "服務設定中的未讀頻道分組無效。必須為 'disabled' 、 'default_on' 或 'default_off'。" + }, { "id": "model.config.is_valid.image_proxy_type.app_error", - "translation": "Invalid image proxy type for service settings." + "translation": "服務設定中的圖片代理類型無效。" }, { "id": "model.config.is_valid.ldap_basedn", @@ -5404,7 +5408,7 @@ }, { "id": "model.user.is_valid.position.app_error", - "translation": "無效的位置:不能超過35個字元。" + "translation": "無效的位置:不能超過128個字元。" }, { "id": "model.user.is_valid.pwd.app_error", @@ -6848,7 +6852,7 @@ }, { "id": "store.sql_user_access_token.get_all.app_error", - "translation": "無法取得個人存取 Token" + "translation": "無法取得全部的個人存取 Token" }, { "id": "store.sql_user_access_token.get_by_token.app_error", @@ -7290,10 +7294,6 @@ "id": "web.root.singup_title", "translation": "註冊" }, - { - "id": "web.signup_team_complete.invalid_link.app_error", - "translation": "此註冊連結不是有效連結" - }, { "id": "web.signup_team_complete.link_expired.app_error", "translation": "註冊連結已過期" @@ -7310,10 +7310,6 @@ "id": "web.signup_user_complete.link_expired.app_error", "translation": "註冊連結已過期" }, - { - "id": "web.signup_user_complete.link_invalid.app_error", - "translation": "此註冊連結不是有效連結" - }, { "id": "web.signup_user_complete.no_invites.app_error", "translation": "此團隊類型不允許自由加入" From 0cbba46018d3879234d3940e8ee6f23dd99a1c5a Mon Sep 17 00:00:00 2001 From: Stephen Kiers Date: Mon, 12 Feb 2018 16:30:03 -0700 Subject: [PATCH 03/42] Fixes ICU-764 --- app/notification.go | 3 +++ app/notification_test.go | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/app/notification.go b/app/notification.go index 1318308f8e..6531f72f73 100644 --- a/app/notification.go +++ b/app/notification.go @@ -832,6 +832,9 @@ func GetExplicitMentions(message string, keywords map[string][]string) *Explicit continue } + // remove trailing '.', as that is the end of a sentence + word = strings.TrimSuffix(word, ".") + if word == "@here" { ret.HereMentioned = true } diff --git a/app/notification_test.go b/app/notification_test.go index 11f4df6857..7f2a63ab99 100644 --- a/app/notification_test.go +++ b/app/notification_test.go @@ -109,6 +109,15 @@ func TestGetExplicitMentions(t *testing.T) { }, }, }, + "OnePersonAtEndOfSentence": { + Message: "this is a message for @user.", + Keywords: map[string][]string{"@user": {id1}}, + Expected: &ExplicitMentions{ + MentionedUserIds: map[string]bool{ + id1: true, + }, + }, + }, "OnePersonWithoutAtMention": { Message: "this is a message for @user", Keywords: map[string][]string{"this": {id1}}, @@ -179,6 +188,15 @@ func TestGetExplicitMentions(t *testing.T) { }, }, }, + "UserWithPeriodAtEndOfSentence": { + Message: "this is a message for user.period.", + Keywords: map[string][]string{"user.period": {id1}}, + Expected: &ExplicitMentions{ + MentionedUserIds: map[string]bool{ + id1: true, + }, + }, + }, "PotentialOutOfChannelUser": { Message: "this is an message for @potential and @user", Keywords: map[string][]string{"@user": {id1}}, From bdf478c75bb41c00cdfd47bd7ae68c70a06886af Mon Sep 17 00:00:00 2001 From: Stephen Kiers Date: Mon, 12 Feb 2018 20:50:20 -0700 Subject: [PATCH 04/42] Added another test --- app/notification_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/notification_test.go b/app/notification_test.go index 7f2a63ab99..61442c0489 100644 --- a/app/notification_test.go +++ b/app/notification_test.go @@ -188,6 +188,15 @@ func TestGetExplicitMentions(t *testing.T) { }, }, }, + "AtUserWithPeriodAtEndOfSentence": { + Message: "this is a message for @user.period.", + Keywords: map[string][]string{"@user.period": {id1}}, + Expected: &ExplicitMentions{ + MentionedUserIds: map[string]bool{ + id1: true, + }, + }, + }, "UserWithPeriodAtEndOfSentence": { Message: "this is a message for user.period.", Keywords: map[string][]string{"user.period": {id1}}, From bda8736770f89399f4bca189bc3559f0141cab8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Tue, 13 Feb 2018 10:43:28 +0100 Subject: [PATCH 05/42] XYZ-111: Check max users per team on re-join after leave a team --- app/team.go | 12 +++++-- app/team_test.go | 89 ++++++++++++++++++++++++++++++++++++++++++++++++ i18n/en.json | 4 +++ 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/app/team.go b/app/team.go index 8e8c29e2ab..a15c64c3fa 100644 --- a/app/team.go +++ b/app/team.go @@ -302,10 +302,16 @@ func (a *App) joinUserToTeam(team *model.Team, user *model.User) (*model.TeamMem return rtm, true, nil } - if tmr := <-a.Srv.Store.Team().UpdateMember(tm); tmr.Err != nil { - return nil, false, tmr.Err + if membersCount := <-a.Srv.Store.Team().GetActiveMemberCount(tm.TeamId); membersCount.Err != nil { + return nil, false, membersCount.Err + } else if membersCount.Data.(int64) >= int64(*a.Config().TeamSettings.MaxUsersPerTeam) { + return nil, false, model.NewAppError("joinUserToTeam", "app.team.join_user_to_team.max_accounts.app_error", nil, "teamId="+tm.TeamId, http.StatusBadRequest) } else { - return tmr.Data.(*model.TeamMember), false, nil + if tmr := <-a.Srv.Store.Team().UpdateMember(tm); tmr.Err != nil { + return nil, false, tmr.Err + } else { + return tmr.Data.(*model.TeamMember), false, nil + } } } else { // Membership appears to be missing. Lets try to add. diff --git a/app/team_test.go b/app/team_test.go index 7cb20b6f69..a2bf44a574 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -460,3 +460,92 @@ func TestAddUserToTeamByHashMismatchedInviteId(t *testing.T) { assert.Nil(t, team) assert.Equal(t, "api.user.create_user.signup_link_mismatched_invite_id.app_error", err.Id) } + +func TestJoinUserToTeam(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + team := &model.Team{ + DisplayName: "dn_" + id, + Name: "name" + id, + Email: "success+" + id + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + + if _, err := th.App.CreateTeam(team); err != nil { + t.Log(err) + t.Fatal("Should create a new team") + } + + maxUsersPerTeam := th.App.Config().TeamSettings.MaxUsersPerTeam + defer func() { + th.App.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.MaxUsersPerTeam = maxUsersPerTeam }) + th.App.SetDefaultRolesBasedOnConfig() + th.App.PermanentDeleteTeam(team) + }() + one := 1 + th.App.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.MaxUsersPerTeam = &one }) + + t.Run("new join", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(&user) + defer th.App.PermanentDeleteUser(&user) + + if _, alreadyAdded, err := th.App.joinUserToTeam(team, ruser); alreadyAdded || err != nil { + t.Fatal("Should return already added equal to false and no error") + } + }) + + t.Run("join when you are a member", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(&user) + defer th.App.PermanentDeleteUser(&user) + + th.App.joinUserToTeam(team, ruser) + if _, alreadyAdded, err := th.App.joinUserToTeam(team, ruser); !alreadyAdded || err != nil { + t.Fatal("Should return already added and no error") + } + }) + + t.Run("re-join after leaving", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(&user) + defer th.App.PermanentDeleteUser(&user) + + th.App.joinUserToTeam(team, ruser) + th.App.LeaveTeam(team, ruser, ruser.Id) + if _, alreadyAdded, err := th.App.joinUserToTeam(team, ruser); alreadyAdded || err != nil { + t.Fatal("Should return already added equal to false and no error") + } + }) + + t.Run("new join with limit problem", func(t *testing.T) { + user1 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser1, _ := th.App.CreateUser(&user1) + user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser2, _ := th.App.CreateUser(&user2) + defer th.App.PermanentDeleteUser(&user1) + defer th.App.PermanentDeleteUser(&user2) + th.App.joinUserToTeam(team, ruser1) + if _, _, err := th.App.joinUserToTeam(team, ruser2); err == nil { + t.Fatal("Should fail") + } + }) + + t.Run("re-join alfter leaving with limit problem", func(t *testing.T) { + user1 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser1, _ := th.App.CreateUser(&user1) + user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser2, _ := th.App.CreateUser(&user2) + defer th.App.PermanentDeleteUser(&user1) + defer th.App.PermanentDeleteUser(&user2) + + th.App.joinUserToTeam(team, ruser1) + th.App.LeaveTeam(team, ruser1, ruser1.Id) + th.App.joinUserToTeam(team, ruser2) + if _, _, err := th.App.joinUserToTeam(team, ruser1); err == nil { + t.Fatal("Should fail") + } + }) +} diff --git a/i18n/en.json b/i18n/en.json index 4365a44fb6..cecec7e1b1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3126,6 +3126,10 @@ "id": "api.websocket_handler.invalid_param.app_error", "translation": "Invalid {{.Name}} parameter" }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "This team has reached the maximum number of allowed accounts. Contact your systems administrator to set a higher limit." + }, { "id": "app.channel.create_channel.no_team_id.app_error", "translation": "Must specify the team ID to create a channel" From b8a4316b13bc0664648abda6b8d3de585d79173d Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Mon, 12 Feb 2018 11:09:51 -0500 Subject: [PATCH 06/42] ICU-753 Remove special behaviour for group messages in generic push notifications --- app/notification.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/notification.go b/app/notification.go index 1318308f8e..2e99ca03c4 100644 --- a/app/notification.go +++ b/app/notification.go @@ -607,7 +607,7 @@ func (a *App) sendPushNotification(post *model.Post, user *model.User, channel * if channel.Type == model.CHANNEL_DIRECT { msg.Category = model.CATEGORY_CAN_REPLY msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message") - } else if wasMentioned || channel.Type == model.CHANNEL_GROUP { + } else if wasMentioned { msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention_no_channel") } else { msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_non_mention_no_channel") @@ -616,7 +616,7 @@ func (a *App) sendPushNotification(post *model.Post, user *model.User, channel * if channel.Type == model.CHANNEL_DIRECT { msg.Category = model.CATEGORY_CAN_REPLY msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message") - } else if wasMentioned || channel.Type == model.CHANNEL_GROUP { + } else if wasMentioned { msg.Category = model.CATEGORY_CAN_REPLY msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName } else { From c1b49f8b77e0e75afcc6cf4dc0f1c36569824151 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Mon, 12 Feb 2018 11:50:41 -0500 Subject: [PATCH 07/42] ICU-753 Added unit tests for push notification contents --- app/notification.go | 90 +++++++++-------- app/notification_test.go | 211 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 39 deletions(-) diff --git a/app/notification.go b/app/notification.go index 2e99ca03c4..83debe584a 100644 --- a/app/notification.go +++ b/app/notification.go @@ -566,8 +566,6 @@ func (a *App) sendPushNotification(post *model.Post, user *model.User, channel * channelName = senderName } - userLocale := utils.GetUserTranslations(user.Locale) - msg := model.PushNotification{} if badge := <-a.Srv.Store.User().GetUnreadCount(user.Id); badge.Err != nil { msg.Badge = 1 @@ -596,44 +594,10 @@ func (a *App) sendPushNotification(post *model.Post, user *model.User, channel * msg.FromWebhook = fw } - if *a.Config().EmailSettings.PushNotificationContents == model.FULL_NOTIFICATION { - msg.Category = model.CATEGORY_CAN_REPLY - if channel.Type == model.CHANNEL_DIRECT { - msg.Message = senderName + ": " + model.ClearMentionTags(post.Message) - } else { - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_in") + channelName + ": " + model.ClearMentionTags(post.Message) - } - } else if *a.Config().EmailSettings.PushNotificationContents == model.GENERIC_NO_CHANNEL_NOTIFICATION { - if channel.Type == model.CHANNEL_DIRECT { - msg.Category = model.CATEGORY_CAN_REPLY - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message") - } else if wasMentioned { - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention_no_channel") - } else { - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_non_mention_no_channel") - } - } else { - if channel.Type == model.CHANNEL_DIRECT { - msg.Category = model.CATEGORY_CAN_REPLY - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_message") - } else if wasMentioned { - msg.Category = model.CATEGORY_CAN_REPLY - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName - } else { - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_non_mention") + channelName - } - } + userLocale := utils.GetUserTranslations(user.Locale) + hasFiles := post.FileIds != nil && len(post.FileIds) > 0 - // If the post only has images then push an appropriate message - if len(post.Message) == 0 && post.FileIds != nil && len(post.FileIds) > 0 { - if channel.Type == model.CHANNEL_DIRECT { - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only_dm") - } else { - msg.Message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only") + channelName - } - } - - //l4g.Debug("Sending push notification for user %v with msg of '%v'", user.Id, msg.Message) + msg.Message, msg.Category = a.getPushNotificationMessage(post.Message, wasMentioned, hasFiles, senderName, channelName, channel.Type, userLocale) for _, session := range sessions { tmpMessage := *model.PushNotificationFromJson(strings.NewReader(msg.ToJson())) @@ -655,6 +619,54 @@ func (a *App) sendPushNotification(post *model.Post, user *model.User, channel * return nil } +func (a *App) getPushNotificationMessage(postMessage string, wasMentioned bool, hasFiles bool, senderName string, channelName string, channelType string, userLocale i18n.TranslateFunc) (string, string) { + message := "" + category := "" + + if *a.Config().EmailSettings.PushNotificationContents == model.FULL_NOTIFICATION { + category = model.CATEGORY_CAN_REPLY + + if channelType == model.CHANNEL_DIRECT { + message = senderName + ": " + model.ClearMentionTags(postMessage) + } else { + message = senderName + userLocale("api.post.send_notifications_and_forget.push_in") + channelName + ": " + model.ClearMentionTags(postMessage) + } + } else if *a.Config().EmailSettings.PushNotificationContents == model.GENERIC_NO_CHANNEL_NOTIFICATION { + if channelType == model.CHANNEL_DIRECT { + category = model.CATEGORY_CAN_REPLY + + message = senderName + userLocale("api.post.send_notifications_and_forget.push_message") + } else if wasMentioned { + message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention_no_channel") + } else { + message = senderName + userLocale("api.post.send_notifications_and_forget.push_non_mention_no_channel") + } + } else { + if channelType == model.CHANNEL_DIRECT { + category = model.CATEGORY_CAN_REPLY + + message = senderName + userLocale("api.post.send_notifications_and_forget.push_message") + } else if wasMentioned { + category = model.CATEGORY_CAN_REPLY + + message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName + } else { + message = senderName + userLocale("api.post.send_notifications_and_forget.push_non_mention") + channelName + } + } + + // If the post only has images then push an appropriate message + if len(message) == 0 && hasFiles { + if channelType == model.CHANNEL_DIRECT { + message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only_dm") + } else { + message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only") + channelName + } + } + + return message, category +} + func (a *App) ClearPushNotification(userId string, channelId string) { a.Go(func() { // Sleep is to allow the read replicas a chance to fully sync diff --git a/app/notification_test.go b/app/notification_test.go index 11f4df6857..2b7b260c48 100644 --- a/app/notification_test.go +++ b/app/notification_test.go @@ -1166,3 +1166,214 @@ func TestGetNotificationEmailBodyGenericNotificationDirectChannel(t *testing.T) t.Fatal("Expected email text '" + teamURL + "'. Got " + body) } } + +func TestGetPushNotificationMessage(t *testing.T) { + th := Setup() + defer th.TearDown() + + for name, tc := range map[string]struct { + Message string + WasMentioned bool + HasFiles bool + Locale string + PushNotificationContents string + ChannelType string + + ExpectedMessage string + ExpectedCategory string + }{ + "full message, public channel, no mention": { + Message: "this is a message", + ChannelType: model.CHANNEL_OPEN, + ExpectedMessage: "user in channel: this is a message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "full message, public channel, mention": { + Message: "this is a message", + WasMentioned: true, + ChannelType: model.CHANNEL_OPEN, + ExpectedMessage: "user in channel: this is a message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "full message, private channel, no mention": { + Message: "this is a message", + ChannelType: model.CHANNEL_PRIVATE, + ExpectedMessage: "user in channel: this is a message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "full message, private channel, mention": { + Message: "this is a message", + WasMentioned: true, + ChannelType: model.CHANNEL_PRIVATE, + ExpectedMessage: "user in channel: this is a message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "full message, group message channel, no mention": { + Message: "this is a message", + ChannelType: model.CHANNEL_GROUP, + ExpectedMessage: "user in channel: this is a message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "full message, group message channel, mention": { + Message: "this is a message", + WasMentioned: true, + ChannelType: model.CHANNEL_GROUP, + ExpectedMessage: "user in channel: this is a message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "full message, direct message channel, no mention": { + Message: "this is a message", + ChannelType: model.CHANNEL_DIRECT, + ExpectedMessage: "user: this is a message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "full message, direct message channel, mention": { + Message: "this is a message", + WasMentioned: true, + ChannelType: model.CHANNEL_DIRECT, + ExpectedMessage: "user: this is a message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "generic message with channel, public channel, no mention": { + Message: "this is a message", + PushNotificationContents: model.GENERIC_NOTIFICATION, + ChannelType: model.CHANNEL_OPEN, + ExpectedMessage: "user posted in channel", + }, + "generic message with channel, public channel, mention": { + Message: "this is a message", + WasMentioned: true, + PushNotificationContents: model.GENERIC_NOTIFICATION, + ChannelType: model.CHANNEL_OPEN, + ExpectedMessage: "user mentioned you in channel", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "generic message with channel, private channel, no mention": { + Message: "this is a message", + PushNotificationContents: model.GENERIC_NOTIFICATION, + ChannelType: model.CHANNEL_PRIVATE, + ExpectedMessage: "user posted in channel", + }, + "generic message with channel, private channel, mention": { + Message: "this is a message", + WasMentioned: true, + PushNotificationContents: model.GENERIC_NOTIFICATION, + ChannelType: model.CHANNEL_PRIVATE, + ExpectedMessage: "user mentioned you in channel", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "generic message with channel, group message channel, no mention": { + Message: "this is a message", + PushNotificationContents: model.GENERIC_NOTIFICATION, + ChannelType: model.CHANNEL_GROUP, + ExpectedMessage: "user posted in channel", + }, + "generic message with channel, group message channel, mention": { + Message: "this is a message", + WasMentioned: true, + PushNotificationContents: model.GENERIC_NOTIFICATION, + ChannelType: model.CHANNEL_GROUP, + ExpectedMessage: "user mentioned you in channel", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "generic message with channel, direct message channel, no mention": { + Message: "this is a message", + PushNotificationContents: model.GENERIC_NOTIFICATION, + ChannelType: model.CHANNEL_DIRECT, + ExpectedMessage: "user sent you a direct message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "generic message with channel, direct message channel, mention": { + Message: "this is a message", + WasMentioned: true, + PushNotificationContents: model.GENERIC_NOTIFICATION, + ChannelType: model.CHANNEL_DIRECT, + ExpectedMessage: "user sent you a direct message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "generic message without channel, public channel, no mention": { + Message: "this is a message", + PushNotificationContents: model.GENERIC_NO_CHANNEL_NOTIFICATION, + ChannelType: model.CHANNEL_OPEN, + ExpectedMessage: "user posted a message", + }, + "generic message without channel, public channel, mention": { + Message: "this is a message", + WasMentioned: true, + PushNotificationContents: model.GENERIC_NO_CHANNEL_NOTIFICATION, + ChannelType: model.CHANNEL_OPEN, + ExpectedMessage: "user mentioned you", + }, + "generic message without channel, private channel, no mention": { + Message: "this is a message", + PushNotificationContents: model.GENERIC_NO_CHANNEL_NOTIFICATION, + ChannelType: model.CHANNEL_PRIVATE, + ExpectedMessage: "user posted a message", + }, + "generic message without channel, private channel, mention": { + Message: "this is a message", + WasMentioned: true, + PushNotificationContents: model.GENERIC_NO_CHANNEL_NOTIFICATION, + ChannelType: model.CHANNEL_PRIVATE, + ExpectedMessage: "user mentioned you", + }, + "generic message without channel, group message channel, no mention": { + Message: "this is a message", + PushNotificationContents: model.GENERIC_NO_CHANNEL_NOTIFICATION, + ChannelType: model.CHANNEL_GROUP, + ExpectedMessage: "user posted a message", + }, + "generic message without channel, group message channel, mention": { + Message: "this is a message", + WasMentioned: true, + PushNotificationContents: model.GENERIC_NO_CHANNEL_NOTIFICATION, + ChannelType: model.CHANNEL_GROUP, + ExpectedMessage: "user mentioned you", + }, + "generic message without channel, direct message channel, no mention": { + Message: "this is a message", + PushNotificationContents: model.GENERIC_NO_CHANNEL_NOTIFICATION, + ChannelType: model.CHANNEL_DIRECT, + ExpectedMessage: "user sent you a direct message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "generic message without channel, direct message channel, mention": { + Message: "this is a message", + WasMentioned: true, + PushNotificationContents: model.GENERIC_NO_CHANNEL_NOTIFICATION, + ChannelType: model.CHANNEL_DIRECT, + ExpectedMessage: "user sent you a direct message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + } { + t.Run(name, func(t *testing.T) { + locale := tc.Locale + if locale == "" { + locale = "en" + } + + pushNotificationContents := tc.PushNotificationContents + if pushNotificationContents == "" { + pushNotificationContents = model.FULL_NOTIFICATION + } + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.PushNotificationContents = pushNotificationContents + }) + + if actualMessage, actualCategory := th.App.getPushNotificationMessage( + tc.Message, + tc.WasMentioned, + tc.HasFiles, + "user", + "channel", + tc.ChannelType, + utils.GetUserTranslations(locale), + ); actualMessage != tc.ExpectedMessage { + t.Fatalf("Received incorrect push notification message `%v`, expected `%v`", actualMessage, tc.ExpectedMessage) + } else if actualCategory != tc.ExpectedCategory { + t.Fatalf("Received incorrect push notification category `%v`, expected `%v`", actualCategory, tc.ExpectedCategory) + } + }) + } +} From 3fef21e350737c235e6dfc2d9f35311d65290c3e Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Mon, 12 Feb 2018 12:56:46 -0500 Subject: [PATCH 08/42] ICU-753 Added unit tests for messages with only push notifications --- app/notification.go | 2 +- app/notification_test.go | 24 ++++++++++++++++++++++++ i18n/en.json | 4 ++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/notification.go b/app/notification.go index 83debe584a..8d8e72cf92 100644 --- a/app/notification.go +++ b/app/notification.go @@ -656,7 +656,7 @@ func (a *App) getPushNotificationMessage(postMessage string, wasMentioned bool, } // If the post only has images then push an appropriate message - if len(message) == 0 && hasFiles { + if len(postMessage) == 0 && hasFiles { if channelType == model.CHANNEL_DIRECT { message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only_dm") } else { diff --git a/app/notification_test.go b/app/notification_test.go index 2b7b260c48..cc501fbd07 100644 --- a/app/notification_test.go +++ b/app/notification_test.go @@ -1345,6 +1345,30 @@ func TestGetPushNotificationMessage(t *testing.T) { ExpectedMessage: "user sent you a direct message", ExpectedCategory: model.CATEGORY_CAN_REPLY, }, + "only files, public channel": { + HasFiles: true, + ChannelType: model.CHANNEL_OPEN, + ExpectedMessage: "user uploaded one or more files in channel", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "only files, private channel": { + HasFiles: true, + ChannelType: model.CHANNEL_PRIVATE, + ExpectedMessage: "user uploaded one or more files in channel", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "only files, group message channel": { + HasFiles: true, + ChannelType: model.CHANNEL_GROUP, + ExpectedMessage: "user uploaded one or more files in channel", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, + "only files, direct message channel": { + HasFiles: true, + ChannelType: model.CHANNEL_DIRECT, + ExpectedMessage: "user uploaded one or more files in a direct message", + ExpectedCategory: model.CATEGORY_CAN_REPLY, + }, } { t.Run(name, func(t *testing.T) { locale := tc.Locale diff --git a/i18n/en.json b/i18n/en.json index 4365a44fb6..b31a6bd099 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1812,11 +1812,11 @@ }, { "id": "api.post.send_notifications_and_forget.push_image_only", - "translation": " Uploaded one or more files in " + "translation": " uploaded one or more files in " }, { "id": "api.post.send_notifications_and_forget.push_image_only_dm", - "translation": " Uploaded one or more files in a direct message" + "translation": " uploaded one or more files in a direct message" }, { "id": "api.post.send_notifications_and_forget.push_in", From 8c22c5c6c6f835a6e73faf19036bad2a51bb9127 Mon Sep 17 00:00:00 2001 From: Jonathan Fritz Date: Tue, 13 Feb 2018 10:48:34 -0500 Subject: [PATCH 09/42] Renamed file to match existing scheme --- ...nelMemberHistoryResult.go => channel_member_history_result.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename model/{ChannelMemberHistoryResult.go => channel_member_history_result.go} (100%) diff --git a/model/ChannelMemberHistoryResult.go b/model/channel_member_history_result.go similarity index 100% rename from model/ChannelMemberHistoryResult.go rename to model/channel_member_history_result.go From 08c21f75199f959bbe63396be246e2b7d36a9a39 Mon Sep 17 00:00:00 2001 From: Stephen Kiers Date: Tue, 13 Feb 2018 10:49:48 -0700 Subject: [PATCH 10/42] Added more tests and simplified code --- app/notification.go | 87 ++++++++++++++++++---------------------- app/notification_test.go | 18 +++++++++ 2 files changed, 57 insertions(+), 48 deletions(-) diff --git a/app/notification.go b/app/notification.go index 6531f72f73..90303fb8fe 100644 --- a/app/notification.go +++ b/app/notification.go @@ -820,46 +820,54 @@ func GetExplicitMentions(message string, keywords map[string][]string) *Explicit } } + checkForMention := func(word string) bool { + isMention := false + + fmt.Printf("New Word: %v\n", word) + + if word == "@here" { + ret.HereMentioned = true + } + + if word == "@channel" { + ret.ChannelMentioned = true + } + + if word == "@all" { + ret.AllMentioned = true + } + + // Non-case-sensitive check for regular keys + if ids, match := keywords[strings.ToLower(word)]; match { + addMentionedUsers(ids) + isMention = true + } + + // Case-sensitive check for first name + if ids, match := keywords[word]; match { + addMentionedUsers(ids) + isMention = true + } + + return isMention + } processText := func(text string) { for _, word := range strings.FieldsFunc(text, func(c rune) bool { // Split on any whitespace or punctuation that can't be part of an at mention or emoji pattern return !(c == ':' || c == '.' || c == '-' || c == '_' || c == '@' || unicode.IsLetter(c) || unicode.IsNumber(c)) }) { - isMention := false - // skip word with format ':word:' with an assumption that it is an emoji format only if word[0] == ':' && word[len(word)-1] == ':' { continue } + if checkForMention(word) { + continue + } + // remove trailing '.', as that is the end of a sentence word = strings.TrimSuffix(word, ".") - - if word == "@here" { - ret.HereMentioned = true - } - - if word == "@channel" { - ret.ChannelMentioned = true - } - - if word == "@all" { - ret.AllMentioned = true - } - - // Non-case-sensitive check for regular keys - if ids, match := keywords[strings.ToLower(word)]; match { - addMentionedUsers(ids) - isMention = true - } - - // Case-sensitive check for first name - if ids, match := keywords[word]; match { - addMentionedUsers(ids) - isMention = true - } - - if isMention { + if checkForMention(word) { continue } @@ -870,27 +878,10 @@ func GetExplicitMentions(message string, keywords map[string][]string) *Explicit }) for _, splitWord := range splitWords { - if splitWord == "@here" { - ret.HereMentioned = true + if checkForMention(splitWord) { + continue } - - if splitWord == "@all" { - ret.AllMentioned = true - } - - if splitWord == "@channel" { - ret.ChannelMentioned = true - } - - // Non-case-sensitive check for regular keys - if ids, match := keywords[strings.ToLower(splitWord)]; match { - addMentionedUsers(ids) - } - - // Case-sensitive check for first name - if ids, match := keywords[splitWord]; match { - addMentionedUsers(ids) - } else if _, ok := systemMentions[splitWord]; !ok && strings.HasPrefix(splitWord, "@") { + if _, ok := systemMentions[splitWord]; !ok && strings.HasPrefix(splitWord, "@") { username := splitWord[1:] ret.OtherPotentialMentions = append(ret.OtherPotentialMentions, username) } diff --git a/app/notification_test.go b/app/notification_test.go index 61442c0489..bd7da3db72 100644 --- a/app/notification_test.go +++ b/app/notification_test.go @@ -109,6 +109,24 @@ func TestGetExplicitMentions(t *testing.T) { }, }, }, + "OnePersonWithPeriodAtEndOfUsername": { + Message: "this is a message for @user.name.", + Keywords: map[string][]string{"@user.name.": {id1}}, + Expected: &ExplicitMentions{ + MentionedUserIds: map[string]bool{ + id1: true, + }, + }, + }, + "OnePersonWithPeriodAtEndOfUsernameButNotSimilarName": { + Message: "this is a message for @user.name.", + Keywords: map[string][]string{"@user.name.": {id1}, "@user.name": {id2}}, + Expected: &ExplicitMentions{ + MentionedUserIds: map[string]bool{ + id1: true, + }, + }, + }, "OnePersonAtEndOfSentence": { Message: "this is a message for @user.", Keywords: map[string][]string{"@user": {id1}}, From 1fe522659b0ecaa61d9c3d652ae2d89dd895248c Mon Sep 17 00:00:00 2001 From: Stephen Kiers Date: Tue, 13 Feb 2018 11:51:38 -0700 Subject: [PATCH 11/42] remove debug statement --- app/notification.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/notification.go b/app/notification.go index 90303fb8fe..7725445e42 100644 --- a/app/notification.go +++ b/app/notification.go @@ -823,8 +823,6 @@ func GetExplicitMentions(message string, keywords map[string][]string) *Explicit checkForMention := func(word string) bool { isMention := false - fmt.Printf("New Word: %v\n", word) - if word == "@here" { ret.HereMentioned = true } From 2544ad70c392db2d859c98d582efad28ce59987d Mon Sep 17 00:00:00 2001 From: Stephen Kiers Date: Tue, 13 Feb 2018 13:01:31 -0700 Subject: [PATCH 12/42] rerun jenkins? --- app/notification.go | 1 + 1 file changed, 1 insertion(+) diff --git a/app/notification.go b/app/notification.go index 7725445e42..30ce833605 100644 --- a/app/notification.go +++ b/app/notification.go @@ -849,6 +849,7 @@ func GetExplicitMentions(message string, keywords map[string][]string) *Explicit return isMention } + processText := func(text string) { for _, word := range strings.FieldsFunc(text, func(c rune) bool { // Split on any whitespace or punctuation that can't be part of an at mention or emoji pattern From 69e56fc04237ae5a821a9d97c48bc830111d6c78 Mon Sep 17 00:00:00 2001 From: Stephen Kiers Date: Tue, 13 Feb 2018 13:20:46 -0700 Subject: [PATCH 13/42] gofmt --- app/notification.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/notification.go b/app/notification.go index 30ce833605..e158e08d59 100644 --- a/app/notification.go +++ b/app/notification.go @@ -819,7 +819,6 @@ func GetExplicitMentions(message string, keywords map[string][]string) *Explicit ret.MentionedUserIds[id] = true } } - checkForMention := func(word string) bool { isMention := false @@ -849,7 +848,6 @@ func GetExplicitMentions(message string, keywords map[string][]string) *Explicit return isMention } - processText := func(text string) { for _, word := range strings.FieldsFunc(text, func(c rune) bool { // Split on any whitespace or punctuation that can't be part of an at mention or emoji pattern From 63d45a0ada41e8491ff218822dfa86551cb5e946 Mon Sep 17 00:00:00 2001 From: Derrick Anderson Date: Wed, 14 Feb 2018 00:12:57 -0500 Subject: [PATCH 14/42] ping jenkins From 7f93d17a5fb5e64a336c44c35b3a41a0c0047783 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 15 Feb 2018 02:04:21 +0000 Subject: [PATCH 15/42] translations PR 20180215 --- i18n/de.json | 6 ++++- i18n/en.json | 8 +++---- i18n/es.json | 6 ++++- i18n/fr.json | 6 ++++- i18n/it.json | 6 ++++- i18n/ja.json | 6 ++++- i18n/ko.json | 10 ++++++--- i18n/nl.json | 36 ++++++++++++++++------------- i18n/pl.json | 6 ++++- i18n/pt-BR.json | 6 ++++- i18n/ru.json | 6 ++++- i18n/tr.json | 6 ++++- i18n/zh-CN.json | 60 ++++++++++++++++++++++++++----------------------- i18n/zh-TW.json | 6 ++++- 14 files changed, 113 insertions(+), 61 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index 4f9ac581fe..bd722e4b30 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "Der Einladungslink scheint nicht gültig zu sein" }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Plugins und/oder Plugin-Uploads wurden deaktiviert." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "Dieses Team hat die maximale Anzahl erlaubter Konten erreicht. Kontaktieren Sie Ihren Systemadministrator, um eine höhere Begrenzung setzen zu lassen." + }, { "id": "app.user_access_token.disabled", "translation": "Persönliche Zugriffs-Token sind auf diesem Server deaktiviert. Bitte kontaktieren Sie ihren Systemadministrator für Details." diff --git a/i18n/en.json b/i18n/en.json index 3f5d0b4d38..ec4450ac81 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3126,10 +3126,6 @@ "id": "api.websocket_handler.invalid_param.app_error", "translation": "Invalid {{.Name}} parameter" }, - { - "id": "app.team.join_user_to_team.max_accounts.app_error", - "translation": "This team has reached the maximum number of allowed accounts. Contact your systems administrator to set a higher limit." - }, { "id": "app.channel.create_channel.no_team_id.app_error", "translation": "Must specify the team ID to create a channel" @@ -3694,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Plugins and/or plugin uploads have been disabled." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "This team has reached the maximum number of allowed accounts. Contact your systems administrator to set a higher limit." + }, { "id": "app.user_access_token.disabled", "translation": "Personal access tokens are disabled on this server. Please contact your system administrator for details." diff --git a/i18n/es.json b/i18n/es.json index b3d472b9f5..b5f6a6489b 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "El enlace de registro parece ser inválido" }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Los Complementos y/o la carga de complementos han sido deshabilitados." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "Este equipo ha alcanzado el número máximo de cuentas permitidas. Contacta a un administrador de sistema para que asigne un límite mayor." + }, { "id": "app.user_access_token.disabled", "translation": "Los tokens de acceso personal están inhabilitados en este servidor. Por favor, póngase en contacto con su administrador del sistema para obtener más detalles." diff --git a/i18n/fr.json b/i18n/fr.json index 66a414e648..60d17a2a99 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "Le lien d'inscription semble ne pas être valide." }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Les plugins et/ou l'envoi de plugins ont été désactivés." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "Cette équipe a atteint la limite du nombre maximum de comptes autorisés. Contactez votre administrateur système pour augmenter cette limite." + }, { "id": "app.user_access_token.disabled", "translation": "Les jetons d'accès personnel sont désactivés sur ce serveur. Veuillez contacter votre administrateur système pour plus d'informations." diff --git a/i18n/it.json b/i18n/it.json index c2f62ae5d0..43a02369df 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "Il collegamento per l'iscrizione non sembra essere valido" }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "I plugin e/o il caricamento dei plugin è stato disattivato." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "Questo gruppo ha raggiunto il limite massimo di utenti ammessi. Contatta il tuo amministratore di sistema per innalzare il limite." + }, { "id": "app.user_access_token.disabled", "translation": "I Token di accesso personale sono disattivati su questo server. Per favore contatta l'Amministratore di Sistema per ulteriori dettagli." diff --git a/i18n/ja.json b/i18n/ja.json index c525f471bc..ac7ec27058 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "利用登録リンクが不正です" }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "プラグインのアップロードは無効化されています。" }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "このチームは登録ユーザー数の上限に達しました。システム管理者に上限値の設定を変更するように依頼してください。" + }, { "id": "app.user_access_token.disabled", "translation": "このサーバーではパーソナルアクセストークンが無効になっています。詳しくはシステム管理者に問い合わせてください。" diff --git a/i18n/ko.json b/i18n/ko.json index 68fb33734c..78fe29d161 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -1812,11 +1812,11 @@ }, { "id": "api.post.send_notifications_and_forget.push_image_only", - "translation": " Uploaded one or more files in " + "translation": " uploaded one or more files in " }, { "id": "api.post.send_notifications_and_forget.push_image_only_dm", - "translation": " Uploaded one or more files in a direct message" + "translation": " uploaded one or more files in a direct message" }, { "id": "api.post.send_notifications_and_forget.push_in", @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "유효하지 않은 가입 링크입니다." }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Plugins and/or plugin uploads have been disabled." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "This team has reached the maximum number of allowed accounts. Contact your systems administrator to set a higher limit." + }, { "id": "app.user_access_token.disabled", "translation": "Personal access tokens are disabled on this server. Please contact your system administrator for details." diff --git a/i18n/nl.json b/i18n/nl.json index 2e02433e4c..107434bca4 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -1812,11 +1812,11 @@ }, { "id": "api.post.send_notifications_and_forget.push_image_only", - "translation": " Uploaded one or more files in " + "translation": " uploaded one or more files in " }, { "id": "api.post.send_notifications_and_forget.push_image_only_dm", - "translation": " Uploaded one or more files in a direct message" + "translation": " uploaded one or more files in a direct message" }, { "id": "api.post.send_notifications_and_forget.push_in", @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "De aanmeld link is niet geldig" }, { "id": "api.user.create_user.team_name.app_error", @@ -3208,11 +3208,11 @@ }, { "id": "app.import.import_line.null_direct_channel.error", - "translation": "Import data line has type \"direct_channel\" but the direct_channel object is null." + "translation": "Geimporteerde lijn met type \"user\", maar het \"user\" object is null." }, { "id": "app.import.import_line.null_direct_post.error", - "translation": "Import data line has type \"direct_post\" but the direct_post object is null." + "translation": "Geimporteerde lijn met type \"user\", maar het \"user\" object is null." }, { "id": "app.import.import_line.null_post.error", @@ -3260,7 +3260,7 @@ }, { "id": "app.import.validate_channel_import_data.display_name_missing.error", - "translation": "Missing required channel property: display_name" + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_channel_import_data.header_length.error", @@ -3284,7 +3284,7 @@ }, { "id": "app.import.validate_channel_import_data.team_missing.error", - "translation": "Missing required channel property: team" + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_channel_import_data.type_invalid.error", @@ -3300,7 +3300,7 @@ }, { "id": "app.import.validate_direct_channel_import_data.members_required.error", - "translation": "Missing required direct channel property: members" + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_direct_channel_import_data.members_too_few.error", @@ -3316,7 +3316,7 @@ }, { "id": "app.import.validate_direct_post_import_data.channel_members_required.error", - "translation": "Missing required direct post property: channel_members" + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_direct_post_import_data.channel_members_too_few.error", @@ -3328,7 +3328,7 @@ }, { "id": "app.import.validate_direct_post_import_data.create_at_missing.error", - "translation": "Missing required direct post property: create_at" + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_direct_post_import_data.create_at_zero.error", @@ -3340,7 +3340,7 @@ }, { "id": "app.import.validate_direct_post_import_data.message_missing.error", - "translation": "Missing required direct post property: message" + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_direct_post_import_data.unknown_flagger.error", @@ -3348,7 +3348,7 @@ }, { "id": "app.import.validate_direct_post_import_data.user_missing.error", - "translation": "Missing required direct post property: user" + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_post_import_data.channel_missing.error", @@ -3356,7 +3356,7 @@ }, { "id": "app.import.validate_post_import_data.create_at_missing.error", - "translation": "Missing required Post property: create_at." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_post_import_data.create_at_zero.error", @@ -3384,7 +3384,7 @@ }, { "id": "app.import.validate_reaction_import_data.create_at_missing.error", - "translation": "Missing required Reaction property: create_at." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_reaction_import_data.create_at_zero.error", @@ -3396,7 +3396,7 @@ }, { "id": "app.import.validate_reaction_import_data.emoji_name_missing.error", - "translation": "Missing required Reaction property: EmojiName." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_reaction_import_data.user_missing.error", @@ -3408,7 +3408,7 @@ }, { "id": "app.import.validate_reply_import_data.create_at_missing.error", - "translation": "Missing required Reply property: create_at." + "translation": "Mist vereiste team kenmerk: naam." }, { "id": "app.import.validate_reply_import_data.create_at_zero.error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Plugins and/or plugin uploads have been disabled." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "Het maximaal aantal leden voor dit team is bereikt. Neem contact op met de beheerder voor een hoger limiet." + }, { "id": "app.user_access_token.disabled", "translation": "Personal access tokens are disabled on this server. Please contact your system administrator for details." diff --git a/i18n/pl.json b/i18n/pl.json index d149f38b08..d2f26cf566 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "Łącze rejestracji wydaje się być niepoprawne." }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Plugins and/or plugin uploads have been disabled." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "Ten zespół osiągnął limit kont. Skontaktuj się z administratorem aby ustanowić wyższy pułap." + }, { "id": "app.user_access_token.disabled", "translation": "Personal access tokens are disabled on this server. Please contact your system administrator for details." diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index 3ec45c2eb7..072981d1f4 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "O link de inscrição não parece ser válido" }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Plugins e/ou envio de plugin foi desabilitado." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "Esta equipe alcançou o número máximo de contas permitidas. Contate o administrador do sistema para ajustar um limite maior." + }, { "id": "app.user_access_token.disabled", "translation": "Os tokens de acesso individual estão desativados neste servidor. Entre em contato com o administrador do sistema para obter detalhes." diff --git a/i18n/ru.json b/i18n/ru.json index cb4d26d451..b203bc482a 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "Неправильная ссылка на регистрацию" }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Plugins and/or plugin uploads have been disabled." }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "Эта команда достигла максимального количества разрешенных учетных записей. Свяжитесь с системным администратором для установки большего предела." + }, { "id": "app.user_access_token.disabled", "translation": "Personal access tokens are disabled on this server. Please contact your system administrator for details." diff --git a/i18n/tr.json b/i18n/tr.json index ae782ddb45..2e159e1e84 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "Hesap açma bağlantısı geçersiz görünüyor" }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "Uygulama ekleri ya da uygulama eki yüklemeleri devre dışı bırakılmış. " }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "Bu takım izin verilen en fazla hesap sayısına ulaşmış. Sınırı yükseltmesi için sistem yöneticinizle görüşün." + }, { "id": "app.user_access_token.disabled", "translation": "Bu sunucu üzerinde kişisel erişim kodları devre dışı bırakılmış. Lütfen ayrıntılı bilgi almak için sistem yöneticiniz ile görüşün." diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index 9186f936e9..8056701691 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -1162,7 +1162,7 @@ }, { "id": "api.emoji.delete.delete_reactions.app_error", - "translation": "无法删除表情符 %v 时删除反应" + "translation": "无法删除表情符 %v 时删除互动" }, { "id": "api.emoji.delete.permissions.app_error", @@ -1904,31 +1904,31 @@ }, { "id": "api.reaction.delete_reaction.mismatched_channel_id.app_error", - "translation": "因网址中频道 ID 与消息 ID不符而删除反应失败" + "translation": "因网址中频道 ID 与消息 ID不符而删除互动失败" }, { "id": "api.reaction.init.debug", - "translation": "正在初始化反应 API 路由" + "translation": "正在初始化互动 API 路由" }, { "id": "api.reaction.list_reactions.mismatched_channel_id.app_error", - "translation": "因网址中频道 ID 与消息 ID不符而获取反应失败" + "translation": "因网址中频道 ID 与消息 ID不符而获取互动失败" }, { "id": "api.reaction.save_reaction.invalid.app_error", - "translation": "无效反应。" + "translation": "无效互动。" }, { "id": "api.reaction.save_reaction.mismatched_channel_id.app_error", - "translation": "因网址中频道 ID 与消息 ID不符而保存反应失败" + "translation": "因网址中频道 ID 与消息 ID不符而保存互动失败" }, { "id": "api.reaction.save_reaction.user_id.app_error", - "translation": "您不能保存其他用户的反应。" + "translation": "您不能保存其他用户的互动。" }, { "id": "api.reaction.send_reaction_event.post.app_error", - "translation": "发送 websocket 反应事件时获取消息失败" + "translation": "发送 websocket 互动事件时获取消息失败" }, { "id": "api.saml.save_certificate.app_error", @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "注册链接无效" }, { "id": "api.user.create_user.team_name.app_error", @@ -3380,31 +3380,31 @@ }, { "id": "app.import.validate_reaction_import_data.create_at_before_parent.error", - "translation": "Reaction CreateAt property must be greater than the parent post CreateAt." + "translation": "互动 CreateAt 属性必须大于父消息 CreateAt。" }, { "id": "app.import.validate_reaction_import_data.create_at_missing.error", - "translation": "缺少反应必须属性:create_at。" + "translation": "缺少互动必须属性:create_at。" }, { "id": "app.import.validate_reaction_import_data.create_at_zero.error", - "translation": "反应 CreateAt 属性不能为零。" + "translation": "互动 CreateAt 属性不能为零。" }, { "id": "app.import.validate_reaction_import_data.emoji_name_length.error", - "translation": "反应 EmojiName 属性超过允许的最大长度。" + "translation": "互动 EmojiName 属性超过允许的最大长度。" }, { "id": "app.import.validate_reaction_import_data.emoji_name_missing.error", - "translation": "缺少反应必须属性:User。" + "translation": "缺少互动必须属性:User。" }, { "id": "app.import.validate_reaction_import_data.user_missing.error", - "translation": "缺少反应必须属性:User。" + "translation": "缺少互动必须属性:User。" }, { "id": "app.import.validate_reply_import_data.create_at_before_parent.error", - "translation": "Reply CreateAt property must be greater than the parent post CreateAt." + "translation": "回复 CreateAt 属性必须大于父消息 CreateAt。" }, { "id": "app.import.validate_reply_import_data.create_at_missing.error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "插件上传已禁用。" }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "这个团队已经达到允许的最大用户数量。请与系统管理员联系以设置更高的限制。" + }, { "id": "app.user_access_token.disabled", "translation": "个人访问令牌在本服务器禁用。请联系您的系统管理员了解详情。" @@ -4760,7 +4764,7 @@ }, { "id": "model.config.is_valid.group_unread_channels.app_error", - "translation": "Invalid group unread channels for service settings. Must be 'disabled', 'default_on', or 'default_off'." + "translation": "无效未读频道分组的服务设定。必须为 'disabled'、'default_on' 或 'default_off'。" }, { "id": "model.config.is_valid.image_proxy_type.app_error", @@ -6384,47 +6388,47 @@ }, { "id": "store.sql_reaction.delete.begin.app_error", - "translation": "删除反应时无法打开事务" + "translation": "删除互动时无法打开事务" }, { "id": "store.sql_reaction.delete.commit.app_error", - "translation": "删除反应时无法提交事务" + "translation": "删除互动时无法提交事务" }, { "id": "store.sql_reaction.delete.save.app_error", - "translation": "无法删除反应" + "translation": "无法删除互动" }, { "id": "store.sql_reaction.delete_all_with_emoj_name.delete_reactions.app_error", - "translation": "无法用提供的表情符删除反应" + "translation": "无法用提供的表情符删除互动" }, { "id": "store.sql_reaction.delete_all_with_emoj_name.get_reactions.app_error", - "translation": "无法用提供的表情符获取反应" + "translation": "无法用提供的表情符获取互动" }, { "id": "store.sql_reaction.delete_all_with_emoji_name.update_post.warn", - "translation": "无法删除反应时更新 Post.HasReactions post_id=%v, error=%v" + "translation": "无法删除互动时更新 Post.HasReactions post_id=%v, error=%v" }, { "id": "store.sql_reaction.get_for_post.app_error", - "translation": "无法获取消息的反应" + "translation": "无法获取消息的互动" }, { "id": "store.sql_reaction.permanent_delete_batch.app_error", - "translation": "批量永久删除反应时遇到错误" + "translation": "批量永久删除互动时遇到错误" }, { "id": "store.sql_reaction.save.begin.app_error", - "translation": "无法保存反应时打开事务" + "translation": "无法保存互动时打开事务" }, { "id": "store.sql_reaction.save.commit.app_error", - "translation": "无法保存反应时提交事务" + "translation": "无法保存互动时提交事务" }, { "id": "store.sql_reaction.save.save.app_error", - "translation": "无法保存反应" + "translation": "无法保存互动" }, { "id": "store.sql_session.analytics_session_count.app_error", diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index e31a359418..eccb6138f4 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -2708,7 +2708,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "The signup link does not appear to be valid" + "translation": "此註冊連結不是有效連結" }, { "id": "api.user.create_user.team_name.app_error", @@ -3690,6 +3690,10 @@ "id": "app.plugin.upload_disabled.app_error", "translation": "模組 跟/或 上傳模組已被停用。" }, + { + "id": "app.team.join_user_to_team.max_accounts.app_error", + "translation": "此團隊已達最大使用者數量上限。請聯絡系統管理員調大數量限制。" + }, { "id": "app.user_access_token.disabled", "translation": "個人存取 Token 在此伺服器被停用。詳情請洽管理員。" From 98bd9a07538891d3ee9aded8865b4454cf72ea32 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 16 Feb 2018 17:03:12 -0500 Subject: [PATCH 16/42] Remove bad field from ChannelMemberHistory model --- model/channel_member_history.go | 1 - 1 file changed, 1 deletion(-) diff --git a/model/channel_member_history.go b/model/channel_member_history.go index bc71b580a7..55435c3206 100644 --- a/model/channel_member_history.go +++ b/model/channel_member_history.go @@ -6,7 +6,6 @@ package model type ChannelMemberHistory struct { ChannelId string UserId string - UserEmail string `db:"Email"` JoinTime int64 LeaveTime *int64 } From 291432e2549d7f36c3c39336d925b036c0ef5de4 Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Fri, 16 Feb 2018 17:14:22 -0500 Subject: [PATCH 17/42] Add upgrade code from 4.7.0 to 4.7.1 --- model/version.go | 1 + store/sqlstore/upgrade.go | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/model/version.go b/model/version.go index 1bd7baecc4..6e461e5d5d 100644 --- a/model/version.go +++ b/model/version.go @@ -13,6 +13,7 @@ import ( // It should be maitained in chronological order with most current // release at the front of the list. var versions = []string{ + "4.7.1", "4.7.0", "4.6.0", "4.5.0", diff --git a/store/sqlstore/upgrade.go b/store/sqlstore/upgrade.go index 75286d2147..25c9e067ac 100644 --- a/store/sqlstore/upgrade.go +++ b/store/sqlstore/upgrade.go @@ -15,6 +15,7 @@ import ( ) const ( + VERSION_4_7_1 = "4.7.1" VERSION_4_7_0 = "4.7.0" VERSION_4_6_0 = "4.6.0" VERSION_4_5_0 = "4.5.0" @@ -64,6 +65,7 @@ func UpgradeDatabase(sqlStore SqlStore) { UpgradeDatabaseToVersion45(sqlStore) UpgradeDatabaseToVersion46(sqlStore) UpgradeDatabaseToVersion47(sqlStore) + UpgradeDatabaseToVersion471(sqlStore) // If the SchemaVersion is empty this this is the first time it has ran // so lets set it to the current version. @@ -349,3 +351,13 @@ func UpgradeDatabaseToVersion47(sqlStore SqlStore) { saveSchemaVersion(sqlStore, VERSION_4_7_0) } } + +// If any new instances started with 4.7, they would have the bad Email column on the +// ChannelMemberHistory table. So for those cases we need to do an upgrade between +// 4.7.0 and 4.7.1 +func UpgradeDatabaseToVersion471(sqlStore SqlStore) { + if shouldPerformUpgrade(sqlStore, VERSION_4_7_0, VERSION_4_7_1) { + sqlStore.RemoveColumnIfExists("ChannelMemberHistory", "Email") + saveSchemaVersion(sqlStore, VERSION_4_7_1) + } +} From c39291597be9642e5dc200bdb0c3730d2b776c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Mon, 19 Feb 2018 21:09:36 +0100 Subject: [PATCH 18/42] Remove unnecesary line --- app/team_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/app/team_test.go b/app/team_test.go index a2bf44a574..cdfec12dad 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -481,7 +481,6 @@ func TestJoinUserToTeam(t *testing.T) { maxUsersPerTeam := th.App.Config().TeamSettings.MaxUsersPerTeam defer func() { th.App.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.MaxUsersPerTeam = maxUsersPerTeam }) - th.App.SetDefaultRolesBasedOnConfig() th.App.PermanentDeleteTeam(team) }() one := 1 From 18a1599e929a41d2fe07f3962bdd2ab60dfca67c Mon Sep 17 00:00:00 2001 From: enahum Date: Tue, 20 Feb 2018 10:43:01 -0300 Subject: [PATCH 19/42] translations PR 20180220 (#8334) --- i18n/de.json | 40 ++++++++++++++++++++++++---------------- i18n/en.json | 40 ++++++++++++++++++++-------------------- i18n/es.json | 44 ++++++++++++++++++++++++++------------------ i18n/fr.json | 46 +++++++++++++++++++++++++++------------------- i18n/it.json | 40 ++++++++++++++++++++++++---------------- i18n/ja.json | 44 ++++++++++++++++++++++++++------------------ i18n/ko.json | 40 ++++++++++++++++++++++++---------------- i18n/nl.json | 40 ++++++++++++++++++++++++---------------- i18n/pl.json | 40 ++++++++++++++++++++++++---------------- i18n/pt-BR.json | 48 ++++++++++++++++++++++++++++-------------------- i18n/ru.json | 40 ++++++++++++++++++++++++---------------- i18n/tr.json | 44 ++++++++++++++++++++++++++------------------ i18n/zh-CN.json | 40 ++++++++++++++++++++++++---------------- i18n/zh-TW.json | 40 ++++++++++++++++++++++++---------------- 14 files changed, 345 insertions(+), 241 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index bd722e4b30..b58cd73b13 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "Dateiupload nicht möglich. Datei ist zu groß." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "Vorlagen in %v werden verarbeitet" - }, { "id": "api.api.init.parsing_templates.error", "translation": "Das Server-Template %v konnte nicht verarbeitet werden" @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v hat das Team verlassen." }, + { + "id": "api.team.move_channel.post.error", + "translation": "Fehler beim Senden des Kanalzwecks" + }, + { + "id": "api.team.move_channel.success", + "translation": "This channel has been moved to this team from %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Versuche Team permanent zu löschen %v id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "Konnte Payload des eingehenden Webhooks nicht verarbeiten." }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "Initialisiere Webhook-API-Routen" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "Nachrichten-Export-Aufgabe \"ExportFromTimestamp\" muss ein Zeitstempel (angegeben in Sekunden seit der Unix-Epoche) sein. Nur Nachrichten nach diesem Zeitpunkt werden exportiert." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "Nachrichten-Export-Aufgabe \"FileLocation\" muss ein beschreibbares Verzeichnis sein in welches die Exportdaten geschrieben werden" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "Nachrichten-Export-Aufgabe \"FileLocation\" muss ein Unterverzeichnis von \"FileSettings.Directory\" sein" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "Minimale Passwortlänge muss eine ganze Zahl größer oder gleich zu {{.MinLength}} und weniger oder gleich zu {{.MaxLength}} sein." @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Fehler beim Öffnen einer Verbindung zum SMTP-Server %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Fehler beim Schließen der Verbindung zum SMTP-Server" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Fehler beim Erstellen der Ordner Überwachung %v" }, - { - "id": "web.dir_fail.error", - "translation": "Fehler beim Erstellen der Ordner Überwachung %v" - }, { "id": "web.do_load_channel.error", "translation": "Fehler beim Finden des Benutzerprofils mit id=%v erzwinge Abmeldung" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "Templates in %v werden geparst" }, - { - "id": "web.parsing_templates.error", - "translation": "Die Templates in %v konnte nicht verarbeitet werden" - }, { "id": "web.post_permalink.app_error", "translation": "Ungültige Nachrichten-ID" }, - { - "id": "web.reparse_templates.info", - "translation": "Erneute Analyze des Templates aufgrund veränderter Datei %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "Der Link zum zurücksetzen des Passwortes ist abgelaufen" diff --git a/i18n/en.json b/i18n/en.json index 10e0899753..033aa85ea8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2298,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v left the team." }, + { + "id": "api.team.move_channel.post.error", + "translation": "Failed to post channel move message." + }, + { + "id": "api.team.move_channel.success", + "translation": "This channel has been moved to this team from %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Attempting to permanently delete team %v id=%v" @@ -3134,14 +3142,6 @@ "id": "app.channel.move_channel.members_do_not_match.error", "translation": "Cannot move a channel unless all its members are already members of the destination team." }, - { - "id": "api.team.move_channel.success", - "translation": "This channel has been moved to this team from %v." - }, - { - "id": "api.team.move_channel.post.error", - "translation": "Failed to post channel move message." - }, { "id": "app.channel.post_update_channel_purpose_message.post.error", "translation": "Failed to post channel purpose message" @@ -4870,14 +4870,6 @@ "id": "model.config.is_valid.message_export.batch_size.app_error", "translation": "Message export job BatchSize must be a positive integer" }, - { - "id": "model.config.is_valid.message_export.export_type.app_error", - "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" - }, - { - "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", - "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" - }, { "id": "model.config.is_valid.message_export.daily_runtime.app_error", "translation": "Message export job DailyRuntime must be a 24-hour time stamp in the form HH:MM." @@ -4890,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "Message export job ExportFromTimestamp must be a timestamp (expressed in seconds since unix epoch). Only messages sent after this timestamp will be exported." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "Message export job FileLocation must be a writable directory that export data will be written to" @@ -4898,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "Message export job FileLocation must be a sub-directory of FileSettings.Directory" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "Minimum password length must be a whole number greater than or equal to {{.MinLength}} and less than or equal to {{.MaxLength}}." @@ -7102,10 +7102,6 @@ "id": "utils.mail.new_client.auth.app_error", "translation": "Failed to authenticate on SMTP server" }, - { - "id": "utils.mail.sendMail.attachments.write_error", - "translation": "Failed to write attachment to email" - }, { "id": "utils.mail.new_client.helo.error", "translation": "Failed to to set the HELO to SMTP server %v" @@ -7114,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Failed to open a connection to SMTP server %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Failed to close connection to SMTP server" diff --git a/i18n/es.json b/i18n/es.json index b5f6a6489b..fbb0c8538d 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "No se pudo cargar el archivo. El archivo es muy grande." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "Analizando el contenido plantillas del servidor en %v" - }, { "id": "api.api.init.parsing_templates.error", "translation": "Falla al analizar el contenido de las plantillas de servidor %v" @@ -1812,11 +1808,11 @@ }, { "id": "api.post.send_notifications_and_forget.push_image_only", - "translation": " Cargó uno o más archivos en " + "translation": " Subió uno o más archivos en " }, { "id": "api.post.send_notifications_and_forget.push_image_only_dm", - "translation": " Cargó uno o más archivos en un mensaje directo" + "translation": " subió uno o más archivos en un mensaje directo" }, { "id": "api.post.send_notifications_and_forget.push_in", @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v abandonó el equipo." }, + { + "id": "api.team.move_channel.post.error", + "translation": "No pudo publicar el mensaje de que el canal fue movido." + }, + { + "id": "api.team.move_channel.success", + "translation": "Este canal ha sido movido a este equipo desde %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Intentando eliminar permanentemente al equipo %v id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "No se pudo leer la carga del webhook de entrada." }, + { + "id": "api.webhook.incoming.error", + "translation": "No se pudo codificar la carga del multipart en el webhook entrante." + }, { "id": "api.webhook.init.debug", "translation": "Inicializando rutas del API para los webhooks" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "El ajuste ExportFromTimestamp para realizar el trabajo de Exportación de Mensajes debe ser una marca de tiempo (expresada en segundos desde la época unix). Sólo los mensajes enviados después de esa fecha serán exportados." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "El trabajo de exportación de Mensaje ExportFormat debe ser 'actiance' o 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "El ajuste FileLocation para realizar el trabajo de Exportación de Mensajes debe ser un directorio con permiso de escritura donde la data de exportación será almacenada." @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "El ajuste FileLocation para realizar el trabajo de Exportación de Mensajes debe ser un sub-directorio de FileSettings.Directory" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "El trabajo de exportación de Mensaje GlobalRelayEmailAddress debe estar asignado a una dirección de correo electrónico válido." + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "Longitud mínima de la contraseña debe ser un número entero mayor que o igual a {{.MinLength}} y menor o igual que {{.MaxLength}}." @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Falla al abrir la conexión al servidor SMTP %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Error al escribir el archivo adjunto al correo electrónico" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Falla al cerrar la conexión al servidor SMTP" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Falla al crear el vigilante de directorio %v" }, - { - "id": "web.dir_fail.error", - "translation": "Falla al vigilar el directorio %v" - }, { "id": "web.do_load_channel.error", "translation": "Error obteniendo el pérfil de usuario para id=%v forzando el cierre de sesión" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "Analizando el contenido de las plantillas en %v" }, - { - "id": "web.parsing_templates.error", - "translation": "Falla al analizar el contenido de las plantillas %v" - }, { "id": "web.post_permalink.app_error", "translation": "El ID del Mensaje es inválido" }, - { - "id": "web.reparse_templates.info", - "translation": "Re-analizando el contenido de las plantillas porque el archivo %v fue modificado" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "El enlace de registro ha expirado" diff --git a/i18n/fr.json b/i18n/fr.json index 60d17a2a99..6b6071d41f 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "Impossible d'envoyer le fichier. Le fichier est trop volumineux." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "Analyse des gabarits du serveur %v en cours" - }, { "id": "api.api.init.parsing_templates.error", "translation": "Impossible d'analyser les modèles du serveur %v" @@ -1812,11 +1808,11 @@ }, { "id": "api.post.send_notifications_and_forget.push_image_only", - "translation": " A envoyé un ou plusieurs fichiers sur " + "translation": " a envoyé un ou plusieurs fichiers dans " }, { "id": "api.post.send_notifications_and_forget.push_image_only_dm", - "translation": " A envoyé un ou plusieurs fichiers dans un message personnel" + "translation": " a envoyé un ou plusieurs fichiers dans un message personnel" }, { "id": "api.post.send_notifications_and_forget.push_in", @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v a quitté l'équipe." }, + { + "id": "api.team.move_channel.post.error", + "translation": "Impossible de publier la description du canal" + }, + { + "id": "api.team.move_channel.success", + "translation": "This channel has been moved to this team from %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Tentative de suppression définitive de l'équipe %v id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "Impossible de lire la charge utile du webhook entrant." }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "Initialisation des routes de l'API webhook" @@ -4648,7 +4656,7 @@ }, { "id": "model.config.is_valid.atmos_camo_image_proxy_options.app_error", - "translation": "Les paramètres d'options du proxy d'image atmos/camo sont invalides. Votre clé partagée doit être définie comme paramètre." + "translation": "Les paramètres d'options du proxy d'images atmos/camo sont invalides. Votre clé partagée doit être définie comme paramètre." }, { "id": "model.config.is_valid.cluster_email_batching.app_error", @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "Le paramètre ExportFromTimestamp de la tâche d'exportation de messages doit être un horodatage (exprimé en secondes depuis l'epoch UNIX). Seuls les messages envoyés après cet horodatage seront exportés." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "Le paramètre FileLocation de la tâche d'exportation de messages doit être un dossier avec droits d'écriture. Il s'agit du dossier dans lequel les données seront exportées." @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "Le paramètre FileLocation de la tâche d'exportation de messages doit être un sous-dossier de FileSettings.Directory." }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "La taille minimale du mot de passe doit être un nombre entier supérieur ou égal à {{.MinLength}} et inférieur ou égal à {{.MaxLength}}." @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Échec de l'ouverture de connexion auprès du serveur SMTP %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Échec de fermeture de connexion auprès du serveur SMTP" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Échec de la création de l'observateur de dossier %v" }, - { - "id": "web.dir_fail.error", - "translation": "Échec de l'observateur de dossier %v" - }, { "id": "web.do_load_channel.error", "translation": "Erreur lors de la récupération du profil pour id=%v déconnexion forcée" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "Analyse des gabarits sur %v" }, - { - "id": "web.parsing_templates.error", - "translation": "Impossible d'analyser les gabarits %v" - }, { "id": "web.post_permalink.app_error", "translation": "ID de message invalide" }, - { - "id": "web.reparse_templates.info", - "translation": "Re-examen des gabarits en raison de la modification du fichier %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "Le lien de réinitialisation du mot de passe a expiré" diff --git a/i18n/it.json b/i18n/it.json index 43a02369df..7b6d3f077d 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "Impossibile caricare il file. Il file è troppo grande." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "Analisi dei template del server: %v" - }, { "id": "api.api.init.parsing_templates.error", "translation": "Impossibile analizzare i modelli del server %v" @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v ha lasciato il gruppo." }, + { + "id": "api.team.move_channel.post.error", + "translation": "Impossibile pubblicare il messaggio di spostamento del canale." + }, + { + "id": "api.team.move_channel.success", + "translation": "Questo canale è stato spostato in questo gruppo da %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Tentativo di eliminazione permanente del gruppo %v id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "Impossibile leggere il contenuto del webhook in ingresso." }, + { + "id": "api.webhook.incoming.error", + "translation": "Impossibile decodificare la richiesta multipart del webhook in ingresso." + }, { "id": "api.webhook.init.debug", "translation": "Inizializzazione API Routes dei webhook" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "Lavoro esportazione messaggi il valore ExportFromTimestamp deve essere un timestamp (espresso in secondi dall'epoca unix). Solo i messaggi successivi a questo valore saranno esportati." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "ExportFormat del lavoro di esportazione messaggi deve essere 'actianve' oppure 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "Lavoro esportazione messaggi il valore FileLocation deve essere una cartella accessibile in scrittura in cui verranno salvati i dati esportati" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "Lavoro esportazione messaggi il valore FileLocation deve essere una sottocartella di FileSettings.Directory" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "GlobalRelayEmailAddress del lavoro di esportazione messaggi deve essere un indirizzo email valido" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "Lunghezza minima password deve essere un numero intero maggiore o uguale a {{.MinLength}} e minore o uguale a {{.MaxLength}}." @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Impossibile aprire una connessione al server SMTP %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Impossibile aggiungere l'allegato all'email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Impossibile chiudere la connessione al server SMTP" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Impossibile creare osservatore cartella %v" }, - { - "id": "web.dir_fail.error", - "translation": "Fallito nell'osservatore cartella %v" - }, { "id": "web.do_load_channel.error", "translation": "Errore nel caricamento del profilo utente id=%v logout forzato" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "Analisi dei template del server: %v" }, - { - "id": "web.parsing_templates.error", - "translation": "Impossibile analizzare i modelli %v" - }, { "id": "web.post_permalink.app_error", "translation": "ID pubblicazione non valido" }, - { - "id": "web.reparse_templates.info", - "translation": "Rianalisi dei modelli in corso a causa della modifica al file %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "Il collegamento per il reset della password è scaduto" diff --git a/i18n/ja.json b/i18n/ja.json index ac7ec27058..b8fb048a5b 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "画像ファイルをアップロードできません。ファイルが大き過ぎます。" }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "%vにあるサーバーテンプレートを読み込んでいます" - }, { "id": "api.api.init.parsing_templates.error", "translation": "サーバーテンプレート%vを読み込めませんでした" @@ -1812,11 +1808,11 @@ }, { "id": "api.post.send_notifications_and_forget.push_image_only", - "translation": " 一つ以上のファイルをアップロードしました " + "translation": " ファイルをアップロードしました " }, { "id": "api.post.send_notifications_and_forget.push_image_only_dm", - "translation": " 一つ以上のファイルをダイレクトメッセージにアップロードしました" + "translation": " ファイルをダイレクトメッセージにアップロードしました" }, { "id": "api.post.send_notifications_and_forget.push_in", @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v はチームから脱退しました。" }, + { + "id": "api.team.move_channel.post.error", + "translation": "チャンネル移動メッセージを投稿できませんでした" + }, + { + "id": "api.team.move_channel.success", + "translation": "このチャンネルは %v からこのチームへ移動されました。" + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "チーム%vを完全に削除しようとしています id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "内向きのウェブフックのペイロードを読み込めませんでした。" }, + { + "id": "api.webhook.incoming.error", + "translation": "内向きのウェブフックのマルチパートペイロードをデコードできませんでした。" + }, { "id": "api.webhook.init.debug", "translation": "ウェブフックAPIルートを初期化しています" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "メッセージエクスポート処理の ExportFromTimestamp は(ユニックス標準時からの秒数で表される)タイムスタンプでなくてはなりません。このタイムスタンプ以降に投稿されたメッセージのみエクスポートされます。" }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "メッセージエクスポート処理の ExportFormat は 'actiance' か 'globalrelay' のいずれかでなくてはなりません" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "メッセーエクスポート処理の FileLocation はエクスポートデータが書き込まれる書き込み可能なディレクトリでなくてはなりません" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "メッセージエクスポート処理の FileLocation は FileSettings.Directory のサブディレクトリでなくてはなりません" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "メッセージエクスポート処理の GlobalRelayEmailAddress には有効な電子メールアドレスが設定されていなくてはなりません" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "最小パスワード長さは、{{.MinLength}}以上、{{.MaxLength}}以下に設定してください。" @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "SMTPサーバー%vに接続できませんでした" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "電子メールへ添付を書き込むことができませんでした" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "SMTPサーバーへの接続を終了できませんでした" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "ディレクトリーウォッチャーを作成できませんでした %v" }, - { - "id": "web.dir_fail.error", - "translation": "ディレクトリーウォッチャーで失敗しました %v" - }, { "id": "web.do_load_channel.error", "translation": "id=%vのユーザーのプロフィールを取得できません。強制的にログアウトします" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "テンプレート%vを読み直しています" }, - { - "id": "web.parsing_templates.error", - "translation": "テンプレート%vの解析に失敗しました" - }, { "id": "web.post_permalink.app_error", "translation": "不正な投稿IDです" }, - { - "id": "web.reparse_templates.info", - "translation": "ファイル%vが変更されたためテンプレートを読み直しています" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "パスワード初期化リンクの期限が切れています" diff --git a/i18n/ko.json b/i18n/ko.json index 78fe29d161..b8eff6bca6 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "용량이 커서 파일을 업로드 할 수 없습니다." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "서버 템플릿 %v 파싱 중" - }, { "id": "api.api.init.parsing_templates.error", "translation": "서버 템플릿 %v 를 파싱하는데 실패하였습니다" @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v left the team." }, + { + "id": "api.team.move_channel.post.error", + "translation": "들어옴/나감 메시지를 등록하는 데 실패함" + }, + { + "id": "api.team.move_channel.success", + "translation": "This channel has been moved to this team from %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Attempting to permanently delete team %v id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "Incoming webhook의 payload를 읽을 수 없습니다." }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "webhook API 경로 초기화 중" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "Message export job ExportFromTimestamp must be a timestamp (expressed in seconds since unix epoch). Only messages sent after this timestamp will be exported." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "Message export job FileLocation must be a writable directory that export data will be written to" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "Message export job FileLocation must be a sub-directory of FileSettings.Directory" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "Minimum password length must be a whole number greater than or equal to {{.MinLength}} and less than or equal to {{.MaxLength}}." @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Failed to open a connection to SMTP server %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Failed to close connection to SMTP server" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Failed to create directory watcher %v" }, - { - "id": "web.dir_fail.error", - "translation": "Failed in directory watcher %v" - }, { "id": "web.do_load_channel.error", "translation": "강제 로그아웃 중 id=%v 유저 프로필을 가져오는 중 오류가 발생했습니다" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "%v에서 서버 템플릿 파싱중" }, - { - "id": "web.parsing_templates.error", - "translation": "서버 템플릿 %v 를 파싱하는데 실패하였습니다" - }, { "id": "web.post_permalink.app_error", "translation": "잘못된 글 ID" }, - { - "id": "web.reparse_templates.info", - "translation": "Re-parsing templates because of modified file %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "비밀번호 재설정 링크가 만료되었습니다." diff --git a/i18n/nl.json b/i18n/nl.json index 107434bca4..a4ae850691 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "Kan bestand niet uploaden. Bestand is te groot." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "Server-templates aan het verwerken voor %v" - }, { "id": "api.api.init.parsing_templates.error", "translation": "Verwerken van server-template %v is mislukt" @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v left the team." }, + { + "id": "api.team.move_channel.post.error", + "translation": "Plaatsen van bericht over binnenkomen/verlaten %v is mislukt" + }, + { + "id": "api.team.move_channel.success", + "translation": "This channel has been moved to this team from %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Poging om team %v permanent te verwijderen, id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "Kan inhoud van inkomende webhook niet lezen." }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "Initialisatie van de webhook api" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "Message export job ExportFromTimestamp must be a timestamp (expressed in seconds since unix epoch). Only messages sent after this timestamp will be exported." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "Message export job FileLocation must be a writable directory that export data will be written to" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "Message export job FileLocation must be a sub-directory of FileSettings.Directory" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "Wachtwoord moet een lengte hebben van minimaal {{.MinLength}} en/of maximaal {{.MaxLength}}" @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Kan geen verbinding opzetten naa de SMTP server, fout=%v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Er is een probleem opgetreden bij het afsluiten van de verbinding naar de SMTP server" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Toevoegen van een directory om te controleren is mislukt %v" }, - { - "id": "web.dir_fail.error", - "translation": "Fout tijdens het bekijken van de directory %v" - }, { "id": "web.do_load_channel.error", "translation": "Probleem bij het ophalen van het gebruikersprofiel voor id=%v, uitlog actie geforceerd" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "Server template aan het verwerken voor %v" }, - { - "id": "web.parsing_templates.error", - "translation": "Server template %v is niet te verwerken" - }, { "id": "web.post_permalink.app_error", "translation": "Ongeldige bericht ID" }, - { - "id": "web.reparse_templates.info", - "translation": "Templates opnieuw verwerken, door aangepast bestand %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "De wachtwoord reset link is niet meer geldig" diff --git a/i18n/pl.json b/i18n/pl.json index d2f26cf566..209c00aebe 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "Nie udało się wgrać obrazu. Plik jest zbyt duży." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "Przetwarzanie szablonów serwera w %v" - }, { "id": "api.api.init.parsing_templates.error", "translation": "Nie udało się przetworzyć szablonów serwera w %v" @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v opuścił zespół." }, + { + "id": "api.team.move_channel.post.error", + "translation": "Nie udało się wysłać celu kanału" + }, + { + "id": "api.team.move_channel.success", + "translation": "This channel has been moved to this team from %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Próba trwałego usunięcia zespołu %v ID=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "Nie można odczytać zawartości przychodzącego webhook." }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "Inicjalizowanie tras API webhooków" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "Message export job ExportFromTimestamp must be a timestamp (expressed in seconds since unix epoch). Only messages sent after this timestamp will be exported." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "Message export job FileLocation must be a writable directory that export data will be written to" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "Message export job FileLocation must be a sub-directory of FileSettings.Directory" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "Minimalna długość hasła musi być liczbą całkowitą większą lub równą od {{.MinLength}} i mniejszą lub równą od {{.MaxLength}}." @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Failed to open a connection to SMTP server %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Failed to close connection to SMTP server" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Nie udało się utworzyć stróża katalogu %v" }, - { - "id": "web.dir_fail.error", - "translation": "Nie udało się utworzyć stróża katalogu %v" - }, { "id": "web.do_load_channel.error", "translation": "Błąd w trakcie pobierania profilów użytkowników dla id=%v wymuszono wylogowanie" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "Przetworzono szablony serwera w %v" }, - { - "id": "web.parsing_templates.error", - "translation": "Nie udało się przetworzyć szablonów %v" - }, { "id": "web.post_permalink.app_error", "translation": "Nieprawidłowy ID wiadomości" }, - { - "id": "web.reparse_templates.info", - "translation": "Re-parsing templates because of modified file %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "Link resetujący hasło wygasł" diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index 072981d1f4..3141312540 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "Não foi possível enviar o arquivo. Arquivo é muito grande." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "Analisando modelos no servidor %v" - }, { "id": "api.api.init.parsing_templates.error", "translation": "Falha ao processar os modelos do servidor %v" @@ -1812,11 +1808,11 @@ }, { "id": "api.post.send_notifications_and_forget.push_image_only", - "translation": " Enviados um ou mais arquivos em " + "translation": " enviado um ou mais arquivos em " }, { "id": "api.post.send_notifications_and_forget.push_image_only_dm", - "translation": " Enviado um ou mais arquivos em uma mensagem direta" + "translation": " enviado um ou mais arquivos em uma mensagem direta" }, { "id": "api.post.send_notifications_and_forget.push_in", @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v deixou a equipe." }, + { + "id": "api.team.move_channel.post.error", + "translation": "Falha ao postar a mensagem de mover o canal." + }, + { + "id": "api.team.move_channel.success", + "translation": "Este canal foi movido desta equipe para %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Tentando permanentemente deletar a equipe %v id=%v" @@ -2368,7 +2372,7 @@ }, { "id": "api.templates.email_info", - "translation": "Qualquer dúvida, envie-nos a qualquer momento: {{.SupportEmail}}.
Nossos melhores cumprimentos,
Equipe {{.SiteName}}
" + "translation": "Se tiver qualquer dúvida, envie-nos um e-mail a qualquer momento: {{.SupportEmail}}.
Nossos melhores cumprimentos,
Equipe {{.SiteName}}
" }, { "id": "api.templates.email_organization", @@ -2708,7 +2712,7 @@ }, { "id": "api.user.create_user.signup_link_mismatched_invite_id.app_error", - "translation": "O link de inscrição não parece ser válido" + "translation": "O link de inscrição não parece estar válido" }, { "id": "api.user.create_user.team_name.app_error", @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "Não foi possível ler o payload do webhook de entrada." }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "Inicializando as rotas de API webhook" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "O trabalho de exportação de mensagens ExportFromTimestamp deve ser data e hora (expresso em segundos unix epoch). Somente mensagens enviadas após está data e hora serão exportadas." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "Trabalho de exportação de mensagens FileLocation deve ser um diretório gravável onde os dados de exportação serão gravados" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "Trabalho de exportação de mensagens FileLocation deve ser um subdiretório de FileSettings.Directory" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "Tamanho mínimo de senha deve ser um número inteiro maior ou igual a {{.MinLength}} e menor ou igual a {{.MaxLength}}." @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Falha ao abrir a conexão SMTP para o servidor %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Falha ao fechar a conexão com servidor SMTP" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Falha ao criar o diretório observador %v" }, - { - "id": "web.dir_fail.error", - "translation": "Falha no diretório observador %v" - }, { "id": "web.do_load_channel.error", "translation": "Erro na obtenção do perfil dos usuários para id=%v forçando o logout" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "Analisando modelos no %v" }, - { - "id": "web.parsing_templates.error", - "translation": "Falha ao processar os modelos %v" - }, { "id": "web.post_permalink.app_error", "translation": "Post ID Inválido" }, - { - "id": "web.reparse_templates.info", - "translation": "Re-análise dos modelos por causa da modificação do arquivo %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "O link de redefinição de senha expirou" diff --git a/i18n/ru.json b/i18n/ru.json index b203bc482a..5d1b6be494 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "Невозможно загрузить файл. Он слишком большой." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "Разбор шаблонов сервера на %v" - }, { "id": "api.api.init.parsing_templates.error", "translation": "Не удалось разобрать шаблоны сервера %v" @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v покинул(а) команду." }, + { + "id": "api.team.move_channel.post.error", + "translation": "Не удалось обновить заголовок канала." + }, + { + "id": "api.team.move_channel.success", + "translation": "Канал перемещен в эту команду из %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "Попытка безвозвратно удалить команду %v id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "Не удалось прочитать полезную нагрузку входящего вебхука." }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "Инициализация API вебхуков" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "Message export job ExportFromTimestamp must be a timestamp (expressed in seconds since unix epoch). Only messages sent after this timestamp will be exported." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "Message export job FileLocation must be a writable directory that export data will be written to" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "Message export job FileLocation must be a sub-directory of FileSettings.Directory" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "Минимальная длина пароля должна быть целым числом большим или равным {{.MinLength}}, а также меньшим или равным {{.MaxLength}}." @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "Не удалось установить соединение с SMTP сервером %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "Не удалось закрыть соединение с SMTP сервером" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Не удалось создать наблюдатель каталога %v" }, - { - "id": "web.dir_fail.error", - "translation": "Сбой в наблюдателе каталога %v" - }, { "id": "web.do_load_channel.error", "translation": "Ошибка при получении профиля пользователя для id=%v, принудительный выход" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "Разбор шаблонов на %v" }, - { - "id": "web.parsing_templates.error", - "translation": "Не удалось разобрать шаблоны %v" - }, { "id": "web.post_permalink.app_error", "translation": "Неверный идентификатор поста" }, - { - "id": "web.reparse_templates.info", - "translation": "Повторный разбор шаблонов из-за модификации файла %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "Ссылка для сброса пароля просрочена" diff --git a/i18n/tr.json b/i18n/tr.json index 2e159e1e84..d335700018 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "Dosya yüklenemedi. Dosya çok büyük." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "%v üzerindeki sunucu kalıpları işleniyor" - }, { "id": "api.api.init.parsing_templates.error", "translation": "%v üzerindeki sunucu kalıpları işlenemedi" @@ -1812,11 +1808,11 @@ }, { "id": "api.post.send_notifications_and_forget.push_image_only", - "translation": " Şunun içine bir ya da bir kaç dosya yüklendi " + "translation": " şunun içine bir ya da bir kaç dosya yükledi " }, { "id": "api.post.send_notifications_and_forget.push_image_only_dm", - "translation": " Bir doğrudan ileti içine bir ya da bir kaç dosya yüklendi" + "translation": " bir doğrudan ileti içine bir ya da bir kaç dosya yükledi" }, { "id": "api.post.send_notifications_and_forget.push_in", @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v takımdan ayrıldı." }, + { + "id": "api.team.move_channel.post.error", + "translation": "Kanal amacı iletisi gönderilemedi" + }, + { + "id": "api.team.move_channel.success", + "translation": "This channel has been moved to this team from %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "%v takımı kalıcı olarak silinmeye çalışılıyor. Kod: %v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "Gelen web bağlantısının yükü okunamadı." }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "Web bağlantısı API rotaları hazırlanıyor" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "İleti dışa aktarma görevi DışaAktarmaZamanıBaşlangıcı değeri bir zaman damgası olmalıdır (Unix Epoch başlangıcından itibaren saniye olarak). Yalnız bu zaman damgasından sonra gönderilmiş iletiler dışa aktarılır." }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "İleti dışa aktarma görevi DosyaKonumu değeri dışa aktarılacak verilerin kaydedilebilmesi için yazılabilen bir klasör olmalıdır" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "İleti dışa aktarma görevi DosyaKonumu DosyaAyarları.Klasör değerinin bir alt klasörü olmalıdır" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "En kısa parola uzunluğu {{.MinLength}} ile {{.MaxLength}} arasında bir tamsayı olmalıdır." @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "%v SMTP sunucusu üzerinde bağlantı açılamadı" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "SMTP sunucu bağlantısı kapatılamadı" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "Klasör izleyici eklenemedi %v" }, - { - "id": "web.dir_fail.error", - "translation": "Klasör izleyicide sorun çıktı %v" - }, { "id": "web.do_load_channel.error", "translation": "%v kodlu kullanıcı profili alınırken sorun çıktı, oturum kapatılıyor" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "%v üzerindeki kalıplar işleniyor" }, - { - "id": "web.parsing_templates.error", - "translation": "%v üzerindeki kalıplar işlenemedi" - }, { "id": "web.post_permalink.app_error", "translation": "İleti kodu geçersiz" }, - { - "id": "web.reparse_templates.info", - "translation": "%v dosyası değiştirildiğinden kalıplar yeniden işleniyor" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "Parola sıfırlama bağlantısının süresi geçmiş" diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index 8056701691..f799bba36e 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "无法上传文件。文件太大。" }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "解析服务模板 %v" - }, { "id": "api.api.init.parsing_templates.error", "translation": "解析服务模板出错 %v" @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v 离开了团队。" }, + { + "id": "api.team.move_channel.post.error", + "translation": "发送频道作用消息失败" + }, + { + "id": "api.team.move_channel.success", + "translation": "This channel has been moved to this team from %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "试图永久删除团队 %v id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "不能读取传入的webhook的负载。" }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "正在初始化 webhook API 路由" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "消息导出任务 ExportFromTimestamp 必须为时间戳 (unix 时间)。只有在此时间后发送的消息会被导出。" }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "消息导出任务 FileLocation 比如为可写的目录" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "消息导出任务 FileLocation 必须为 FileSettings.Directory 的子目录" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "最短密码长度必须为整数大于或等于 {{.MinLength}} 以及小于或等于 {{.MaxLength}}。" @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "无法打开一个SMTP服务器连接 %v" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "未能关闭连接到SMTP服务器" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "创建目录监视器失败 %v" }, - { - "id": "web.dir_fail.error", - "translation": "目录中的监视器失败 %v" - }, { "id": "web.do_load_channel.error", "translation": "获取用户配置文件时出错 id=%v 强制注销" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "解析模板 %v" }, - { - "id": "web.parsing_templates.error", - "translation": "解析模板失败 %v" - }, { "id": "web.post_permalink.app_error", "translation": "无效Post ID" }, - { - "id": "web.reparse_templates.info", - "translation": "修改文件后重新解析模板 %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "密码重置链接已过期" diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index eccb6138f4..292b05879c 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "無法上傳檔案。檔案過大。" }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "解析伺服器樣版:%v" - }, { "id": "api.api.init.parsing_templates.error", "translation": "解析伺服器樣板 %v 失敗" @@ -2302,6 +2298,14 @@ "id": "api.team.leave.left", "translation": "%v 已離開團隊。 " }, + { + "id": "api.team.move_channel.post.error", + "translation": "發送頻道用途訊息失敗" + }, + { + "id": "api.team.move_channel.success", + "translation": "This channel has been moved to this team from %v." + }, { "id": "api.team.permanent_delete_team.attempting.warn", "translation": "正在嘗試永久刪除團隊 %v id=%v" @@ -3066,6 +3070,10 @@ "id": "api.webhook.incoming.debug.error", "translation": "無法讀取傳入的 Webhook 的內容。" }, + { + "id": "api.webhook.incoming.error", + "translation": "Could not decode the multipart payload of incoming webhook." + }, { "id": "api.webhook.init.debug", "translation": "正在初始化 Webhook API 路徑" @@ -4874,6 +4882,10 @@ "id": "model.config.is_valid.message_export.export_from.app_error", "translation": "匯出訊息工作 ExportFromTimestamp 設定必須為時間戳記 (以 Unix Epoch 以來的秒數表示)。只有在此時間戳記之後發送的訊息會被匯出。" }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, { "id": "model.config.is_valid.message_export.file_location.app_error", "translation": "匯出訊息工作 FileLocation 設定必須為可寫入的目錄,匯出資料將寫入此處" @@ -4882,6 +4894,10 @@ "id": "model.config.is_valid.message_export.file_location.relative", "translation": "匯出訊息工作 FileLocation 設定必須為 FileSettings.Directory 的子目錄" }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.password_length.app_error", "translation": "密碼最小長度必須是一個整數且大於或等於{{.MinLength}}同時小於或等於{{.MaxLength}}。" @@ -7094,6 +7110,10 @@ "id": "utils.mail.new_client.open.error", "translation": "開啟對 SMTP 伺服器 %v 的連線時失敗" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.send_mail.close.app_error", "translation": "關閉對 SMTP 伺服器的連線失敗" @@ -7162,10 +7182,6 @@ "id": "web.create_dir.error", "translation": "建立目錄監控 %v 失敗" }, - { - "id": "web.dir_fail.error", - "translation": "建立目錄監控 %v 失敗" - }, { "id": "web.do_load_channel.error", "translation": "取得使用者 id=%v 資訊時遇到錯誤,強制登出" @@ -7270,18 +7286,10 @@ "id": "web.parsing_templates.debug", "translation": "於 %v 解析樣板" }, - { - "id": "web.parsing_templates.error", - "translation": "解析樣板 %v 失敗" - }, { "id": "web.post_permalink.app_error", "translation": "無效的張貼 ID" }, - { - "id": "web.reparse_templates.info", - "translation": "因檔案 %v 變動因此重新解析樣板" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "密碼重設連結已過期" From f85d9105925a82c5006654b99060506a0ff7d7af Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 20 Feb 2018 08:51:01 -0500 Subject: [PATCH 20/42] Added t.Helper to CheckXStatus test functions (#8305) --- api4/apitestlib.go | 49 ++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/api4/apitestlib.go b/api4/apitestlib.go index db43a65123..e55ca8c8b0 100644 --- a/api4/apitestlib.go +++ b/api4/apitestlib.go @@ -12,7 +12,6 @@ import ( "net/http" "os" "reflect" - "runtime/debug" "strconv" "strings" "sync" @@ -496,6 +495,8 @@ func GenerateTestId() string { } func CheckUserSanitization(t *testing.T, user *model.User) { + t.Helper() + if user.Password != "" { t.Fatal("password wasn't blank") } @@ -510,6 +511,8 @@ func CheckUserSanitization(t *testing.T, user *model.User) { } func CheckTeamSanitization(t *testing.T, team *model.Team) { + t.Helper() + if team.Email != "" { t.Fatal("email wasn't blank") } @@ -520,13 +523,13 @@ func CheckTeamSanitization(t *testing.T, team *model.Team) { } func CheckEtag(t *testing.T, data interface{}, resp *model.Response) { + t.Helper() + if !reflect.ValueOf(data).IsNil() { - debug.PrintStack() t.Fatal("etag data was not nil") } if resp.StatusCode != http.StatusNotModified { - debug.PrintStack() t.Log("actual: " + strconv.Itoa(resp.StatusCode)) t.Log("expected: " + strconv.Itoa(http.StatusNotModified)) t.Fatal("wrong status code for etag") @@ -534,15 +537,17 @@ func CheckEtag(t *testing.T, data interface{}, resp *model.Response) { } func CheckNoError(t *testing.T, resp *model.Response) { + t.Helper() + if resp.Error != nil { - debug.PrintStack() t.Fatal("Expected no error, got " + resp.Error.Error()) } } func CheckCreatedStatus(t *testing.T, resp *model.Response) { + t.Helper() + if resp.StatusCode != http.StatusCreated { - debug.PrintStack() t.Log("actual: " + strconv.Itoa(resp.StatusCode)) t.Log("expected: " + strconv.Itoa(http.StatusCreated)) t.Fatal("wrong status code") @@ -550,14 +555,14 @@ func CheckCreatedStatus(t *testing.T, resp *model.Response) { } func CheckForbiddenStatus(t *testing.T, resp *model.Response) { + t.Helper() + if resp.Error == nil { - debug.PrintStack() t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusForbidden)) return } if resp.StatusCode != http.StatusForbidden { - debug.PrintStack() t.Log("actual: " + strconv.Itoa(resp.StatusCode)) t.Log("expected: " + strconv.Itoa(http.StatusForbidden)) t.Fatal("wrong status code") @@ -565,14 +570,14 @@ func CheckForbiddenStatus(t *testing.T, resp *model.Response) { } func CheckUnauthorizedStatus(t *testing.T, resp *model.Response) { + t.Helper() + if resp.Error == nil { - debug.PrintStack() t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusUnauthorized)) return } if resp.StatusCode != http.StatusUnauthorized { - debug.PrintStack() t.Log("actual: " + strconv.Itoa(resp.StatusCode)) t.Log("expected: " + strconv.Itoa(http.StatusUnauthorized)) t.Fatal("wrong status code") @@ -580,14 +585,14 @@ func CheckUnauthorizedStatus(t *testing.T, resp *model.Response) { } func CheckNotFoundStatus(t *testing.T, resp *model.Response) { + t.Helper() + if resp.Error == nil { - debug.PrintStack() t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusNotFound)) return } if resp.StatusCode != http.StatusNotFound { - debug.PrintStack() t.Log("actual: " + strconv.Itoa(resp.StatusCode)) t.Log("expected: " + strconv.Itoa(http.StatusNotFound)) t.Fatal("wrong status code") @@ -595,14 +600,14 @@ func CheckNotFoundStatus(t *testing.T, resp *model.Response) { } func CheckBadRequestStatus(t *testing.T, resp *model.Response) { + t.Helper() + if resp.Error == nil { - debug.PrintStack() t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusBadRequest)) return } if resp.StatusCode != http.StatusBadRequest { - debug.PrintStack() t.Log("actual: " + strconv.Itoa(resp.StatusCode)) t.Log("expected: " + strconv.Itoa(http.StatusBadRequest)) t.Fatal("wrong status code") @@ -610,14 +615,14 @@ func CheckBadRequestStatus(t *testing.T, resp *model.Response) { } func CheckNotImplementedStatus(t *testing.T, resp *model.Response) { + t.Helper() + if resp.Error == nil { - debug.PrintStack() t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusNotImplemented)) return } if resp.StatusCode != http.StatusNotImplemented { - debug.PrintStack() t.Log("actual: " + strconv.Itoa(resp.StatusCode)) t.Log("expected: " + strconv.Itoa(http.StatusNotImplemented)) t.Fatal("wrong status code") @@ -625,6 +630,8 @@ func CheckNotImplementedStatus(t *testing.T, resp *model.Response) { } func CheckOKStatus(t *testing.T, resp *model.Response) { + t.Helper() + CheckNoError(t, resp) if resp.StatusCode != http.StatusOK { @@ -633,14 +640,14 @@ func CheckOKStatus(t *testing.T, resp *model.Response) { } func CheckErrorMessage(t *testing.T, resp *model.Response, errorId string) { + t.Helper() + if resp.Error == nil { - debug.PrintStack() t.Fatal("should have errored with message:" + errorId) return } if resp.Error.Id != errorId { - debug.PrintStack() t.Log("actual: " + resp.Error.Id) t.Log("expected: " + errorId) t.Fatal("incorrect error message") @@ -648,14 +655,14 @@ func CheckErrorMessage(t *testing.T, resp *model.Response, errorId string) { } func CheckInternalErrorStatus(t *testing.T, resp *model.Response) { + t.Helper() + if resp.Error == nil { - debug.PrintStack() t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusInternalServerError)) return } if resp.StatusCode != http.StatusInternalServerError { - debug.PrintStack() t.Log("actual: " + strconv.Itoa(resp.StatusCode)) t.Log("expected: " + strconv.Itoa(http.StatusInternalServerError)) t.Fatal("wrong status code") @@ -663,14 +670,14 @@ func CheckInternalErrorStatus(t *testing.T, resp *model.Response) { } func CheckPayLoadTooLargeStatus(t *testing.T, resp *model.Response) { + t.Helper() + if resp.Error == nil { - debug.PrintStack() t.Fatal("should have errored with status:" + strconv.Itoa(http.StatusRequestEntityTooLarge)) return } if resp.StatusCode != http.StatusRequestEntityTooLarge { - debug.PrintStack() t.Log("actual: " + strconv.Itoa(resp.StatusCode)) t.Log("expected: " + strconv.Itoa(http.StatusRequestEntityTooLarge)) t.Fatal("wrong status code") From babd795d792e95f6e708af6ee8207ef6877e2b32 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 20 Feb 2018 10:41:00 -0500 Subject: [PATCH 21/42] MM-9556 Added ability to upload files without a multipart request (#8306) * MM-9556 Added ability to upload files without a multipart request * MM-9556 Handled some unusual test behaviour --- api/file.go | 2 +- api4/context.go | 12 +++++ api4/file.go | 75 +++++++++++++++++++-------- api4/file_test.go | 129 +++++++++++++++++++++++++++++++++++++++++++++- api4/params.go | 14 +++-- app/file.go | 39 ++++++++++---- i18n/en.json | 4 ++ model/client4.go | 9 +++- 8 files changed, 245 insertions(+), 39 deletions(-) diff --git a/api/file.go b/api/file.go index 3b8984816d..63432eda58 100644 --- a/api/file.go +++ b/api/file.go @@ -76,7 +76,7 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { return } - resStruct, err := c.App.UploadFiles(c.TeamId, channelId, c.Session.UserId, m.File["files"], m.Value["client_ids"]) + resStruct, err := c.App.UploadMultipartFiles(c.TeamId, channelId, c.Session.UserId, m.File["files"], m.Value["client_ids"]) if err != nil { c.Err = err return diff --git a/api4/context.go b/api4/context.go index 82c8d9e6c2..9f60ab01ea 100644 --- a/api4/context.go +++ b/api4/context.go @@ -447,6 +447,18 @@ func (c *Context) RequireFileId() *Context { return c } +func (c *Context) RequireFilename() *Context { + if c.Err != nil { + return c + } + + if len(c.Params.Filename) == 0 { + c.SetInvalidUrlParam("filename") + } + + return c +} + func (c *Context) RequirePluginId() *Context { if c.Err != nil { return c diff --git a/api4/file.go b/api4/file.go index acc4c78e58..0b0973b309 100644 --- a/api4/file.go +++ b/api4/file.go @@ -4,6 +4,7 @@ package api4 import ( + "io" "net/http" "net/url" "strconv" @@ -65,32 +66,62 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { return } - if err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize); err != nil { + var resStruct *model.FileUploadResponse + var appErr *model.AppError + + if err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize); err != nil && err != http.ErrNotMultipart { http.Error(w, err.Error(), http.StatusInternalServerError) return + } else if err == http.ErrNotMultipart { + defer r.Body.Close() + + c.RequireChannelId() + c.RequireFilename() + + if c.Err != nil { + return + } + + channelId := c.Params.ChannelId + filename := c.Params.Filename + + if !c.App.SessionHasPermissionToChannel(c.Session, channelId, model.PERMISSION_UPLOAD_FILE) { + c.SetPermissionError(model.PERMISSION_UPLOAD_FILE) + return + } + + resStruct, appErr = c.App.UploadFiles( + FILE_TEAM_ID, + channelId, + c.Session.UserId, + []io.ReadCloser{r.Body}, + []string{filename}, + []string{}, + ) + } else { + m := r.MultipartForm + + props := m.Value + if len(props["channel_id"]) == 0 { + c.SetInvalidParam("channel_id") + return + } + channelId := props["channel_id"][0] + if len(channelId) == 0 { + c.SetInvalidParam("channel_id") + return + } + + if !c.App.SessionHasPermissionToChannel(c.Session, channelId, model.PERMISSION_UPLOAD_FILE) { + c.SetPermissionError(model.PERMISSION_UPLOAD_FILE) + return + } + + resStruct, appErr = c.App.UploadMultipartFiles(FILE_TEAM_ID, channelId, c.Session.UserId, m.File["files"], m.Value["client_ids"]) } - m := r.MultipartForm - - props := m.Value - if len(props["channel_id"]) == 0 { - c.SetInvalidParam("channel_id") - return - } - channelId := props["channel_id"][0] - if len(channelId) == 0 { - c.SetInvalidParam("channel_id") - return - } - - if !c.App.SessionHasPermissionToChannel(c.Session, channelId, model.PERMISSION_UPLOAD_FILE) { - c.SetPermissionError(model.PERMISSION_UPLOAD_FILE) - return - } - - resStruct, err := c.App.UploadFiles(FILE_TEAM_ID, channelId, c.Session.UserId, m.File["files"], m.Value["client_ids"]) - if err != nil { - c.Err = err + if appErr != nil { + c.Err = appErr return } diff --git a/api4/file_test.go b/api4/file_test.go index 7010b30396..a28420c768 100644 --- a/api4/file_test.go +++ b/api4/file_test.go @@ -14,7 +14,7 @@ import ( "github.com/mattermost/mattermost-server/store" ) -func TestUploadFile(t *testing.T) { +func TestUploadFileAsMultipart(t *testing.T) { th := Setup().InitBasic().InitSystemAdmin() defer th.TearDown() Client := th.Client @@ -119,7 +119,132 @@ func TestUploadFile(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnableFileAttachments = false }) _, resp = th.SystemAdminClient.UploadFile(data, channel.Id, "test.png") - if resp.StatusCode != http.StatusNotImplemented && resp.StatusCode != 0 { + if resp.StatusCode == 0 { + t.Log("file upload request failed completely") + } else if resp.StatusCode != http.StatusNotImplemented { + // This should return an HTTP 501, but it occasionally causes the http client itself to error + t.Fatalf("should've returned HTTP 501 or failed completely, got %v instead", resp.StatusCode) + } +} + +func TestUploadFileAsRequestBody(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer th.TearDown() + Client := th.Client + + user := th.BasicUser + channel := th.BasicChannel + + var uploadInfo *model.FileInfo + var data []byte + var err error + if data, err = readTestFile("test.png"); err != nil { + t.Fatal(err) + } else if fileResp, resp := Client.UploadFileAsRequestBody(data, channel.Id, "test.png"); resp.Error != nil { + t.Fatal(resp.Error) + } else if len(fileResp.FileInfos) != 1 { + t.Fatal("should've returned a single file infos") + } else { + uploadInfo = fileResp.FileInfos[0] + } + + // The returned file info from the upload call will be missing some fields that will be stored in the database + if uploadInfo.CreatorId != user.Id { + t.Fatal("file should be assigned to user") + } else if uploadInfo.PostId != "" { + t.Fatal("file shouldn't have a post") + } else if uploadInfo.Path != "" { + t.Fatal("file path should not be set on returned info") + } else if uploadInfo.ThumbnailPath != "" { + t.Fatal("file thumbnail path should not be set on returned info") + } else if uploadInfo.PreviewPath != "" { + t.Fatal("file preview path should not be set on returned info") + } + + var info *model.FileInfo + if result := <-th.App.Srv.Store.FileInfo().Get(uploadInfo.Id); result.Err != nil { + t.Fatal(result.Err) + } else { + info = result.Data.(*model.FileInfo) + } + + if info.Id != uploadInfo.Id { + t.Fatal("file id from response should match one stored in database") + } else if info.CreatorId != user.Id { + t.Fatal("file should be assigned to user") + } else if info.PostId != "" { + t.Fatal("file shouldn't have a post") + } else if info.Path == "" { + t.Fatal("file path should be set in database") + } else if info.ThumbnailPath == "" { + t.Fatal("file thumbnail path should be set in database") + } else if info.PreviewPath == "" { + t.Fatal("file preview path should be set in database") + } + + date := time.Now().Format("20060102") + + // This also makes sure that the relative path provided above is sanitized out + expectedPath := fmt.Sprintf("%v/teams/%v/channels/%v/users/%v/%v/test.png", date, FILE_TEAM_ID, channel.Id, user.Id, info.Id) + if info.Path != expectedPath { + t.Logf("file is saved in %v", info.Path) + t.Fatalf("file should've been saved in %v", expectedPath) + } + + expectedThumbnailPath := fmt.Sprintf("%v/teams/%v/channels/%v/users/%v/%v/test_thumb.jpg", date, FILE_TEAM_ID, channel.Id, user.Id, info.Id) + if info.ThumbnailPath != expectedThumbnailPath { + t.Logf("file thumbnail is saved in %v", info.ThumbnailPath) + t.Fatalf("file thumbnail should've been saved in %v", expectedThumbnailPath) + } + + expectedPreviewPath := fmt.Sprintf("%v/teams/%v/channels/%v/users/%v/%v/test_preview.jpg", date, FILE_TEAM_ID, channel.Id, user.Id, info.Id) + if info.PreviewPath != expectedPreviewPath { + t.Logf("file preview is saved in %v", info.PreviewPath) + t.Fatalf("file preview should've been saved in %v", expectedPreviewPath) + } + + // Wait a bit for files to ready + time.Sleep(2 * time.Second) + + if err := th.cleanupTestFile(info); err != nil { + t.Fatal(err) + } + + _, resp := Client.UploadFileAsRequestBody(data, model.NewId(), "test.png") + CheckForbiddenStatus(t, resp) + + _, resp = Client.UploadFileAsRequestBody(data, "../../junk", "test.png") + if resp.StatusCode == 0 { + t.Log("file upload request failed completely") + } else if resp.StatusCode != http.StatusBadRequest { + // This should return an HTTP 400, but it occasionally causes the http client itself to error + t.Fatalf("should've returned HTTP 400 or failed completely, got %v instead", resp.StatusCode) + } + + _, resp = th.SystemAdminClient.UploadFileAsRequestBody(data, model.NewId(), "test.png") + CheckForbiddenStatus(t, resp) + + _, resp = th.SystemAdminClient.UploadFileAsRequestBody(data, "../../junk", "test.png") + if resp.StatusCode == 0 { + t.Log("file upload request failed completely") + } else if resp.StatusCode != http.StatusBadRequest { + // This should return an HTTP 400, but it occasionally causes the http client itself to error + t.Fatalf("should've returned HTTP 400 or failed completely, got %v instead", resp.StatusCode) + } + + _, resp = th.SystemAdminClient.UploadFileAsRequestBody(data, channel.Id, "test.png") + CheckNoError(t, resp) + + enableFileAttachments := *th.App.Config().FileSettings.EnableFileAttachments + defer func() { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnableFileAttachments = enableFileAttachments }) + }() + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.EnableFileAttachments = false }) + + _, resp = th.SystemAdminClient.UploadFileAsRequestBody(data, channel.Id, "test.png") + if resp.StatusCode == 0 { + t.Log("file upload request failed completely") + } else if resp.StatusCode != http.StatusNotImplemented { // This should return an HTTP 501, but it occasionally causes the http client itself to error t.Fatalf("should've returned HTTP 501 or failed completely, got %v instead", resp.StatusCode) } diff --git a/api4/params.go b/api4/params.go index 30638578b8..070efbbc6b 100644 --- a/api4/params.go +++ b/api4/params.go @@ -27,6 +27,7 @@ type ApiParams struct { ChannelId string PostId string FileId string + Filename string PluginId string CommandId string HookId string @@ -54,6 +55,7 @@ func ApiParamsFromRequest(r *http.Request) *ApiParams { params := &ApiParams{} props := mux.Vars(r) + query := r.URL.Query() if val, ok := props["user_id"]; ok { params.UserId = val @@ -73,6 +75,8 @@ func ApiParamsFromRequest(r *http.Request) *ApiParams { if val, ok := props["channel_id"]; ok { params.ChannelId = val + } else { + params.ChannelId = query.Get("channel_id") } if val, ok := props["post_id"]; ok { @@ -83,6 +87,8 @@ func ApiParamsFromRequest(r *http.Request) *ApiParams { params.FileId = val } + params.Filename = query.Get("filename") + if val, ok := props["plugin_id"]; ok { params.PluginId = val } @@ -151,17 +157,17 @@ func ApiParamsFromRequest(r *http.Request) *ApiParams { params.ActionId = val } - if val, err := strconv.Atoi(r.URL.Query().Get("page")); err != nil || val < 0 { + if val, err := strconv.Atoi(query.Get("page")); err != nil || val < 0 { params.Page = PAGE_DEFAULT } else { params.Page = val } - if val, err := strconv.ParseBool(r.URL.Query().Get("permanent")); err != nil { + if val, err := strconv.ParseBool(query.Get("permanent")); err != nil { params.Permanent = val } - if val, err := strconv.Atoi(r.URL.Query().Get("per_page")); err != nil || val < 0 { + if val, err := strconv.Atoi(query.Get("per_page")); err != nil || val < 0 { params.PerPage = PER_PAGE_DEFAULT } else if val > PER_PAGE_MAXIMUM { params.PerPage = PER_PAGE_MAXIMUM @@ -169,7 +175,7 @@ func ApiParamsFromRequest(r *http.Request) *ApiParams { params.PerPage = val } - if val, err := strconv.Atoi(r.URL.Query().Get("logs_per_page")); err != nil || val < 0 { + if val, err := strconv.Atoi(query.Get("logs_per_page")); err != nil || val < 0 { params.LogsPerPage = LOGS_PER_PAGE_DEFAULT } else if val > LOGS_PER_PAGE_MAXIMUM { params.LogsPerPage = LOGS_PER_PAGE_MAXIMUM diff --git a/app/file.go b/app/file.go index bb20585bb2..06ee61c920 100644 --- a/app/file.go +++ b/app/file.go @@ -280,11 +280,38 @@ func GeneratePublicLinkHash(fileId, salt string) string { return base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) } -func (a *App) UploadFiles(teamId string, channelId string, userId string, fileHeaders []*multipart.FileHeader, clientIds []string) (*model.FileUploadResponse, *model.AppError) { +func (a *App) UploadMultipartFiles(teamId string, channelId string, userId string, fileHeaders []*multipart.FileHeader, clientIds []string) (*model.FileUploadResponse, *model.AppError) { + files := make([]io.ReadCloser, len(fileHeaders)) + filenames := make([]string, len(fileHeaders)) + + for i, fileHeader := range fileHeaders { + file, fileErr := fileHeader.Open() + if fileErr != nil { + return nil, model.NewAppError("UploadFiles", "api.file.upload_file.bad_parse.app_error", nil, fileErr.Error(), http.StatusBadRequest) + } + + // Will be closed after UploadFiles returns + defer file.Close() + + files[i] = file + filenames[i] = fileHeader.Filename + } + + return a.UploadFiles(teamId, channelId, userId, files, filenames, clientIds) +} + +// Uploads some files to the given team and channel as the given user. files and filenames should have +// the same length. clientIds should either not be provided or have the same length as files and filenames. +// The provided files should be closed by the caller so that they are not leaked. +func (a *App) UploadFiles(teamId string, channelId string, userId string, files []io.ReadCloser, filenames []string, clientIds []string) (*model.FileUploadResponse, *model.AppError) { if len(*a.Config().FileSettings.DriverName) == 0 { return nil, model.NewAppError("uploadFile", "api.file.upload_file.storage.app_error", nil, "", http.StatusNotImplemented) } + if len(filenames) != len(files) || (len(clientIds) > 0 && len(clientIds) != len(files)) { + return nil, model.NewAppError("UploadFiles", "api.file.upload_file.incorrect_number_of_files.app_error", nil, "", http.StatusBadRequest) + } + resStruct := &model.FileUploadResponse{ FileInfos: []*model.FileInfo{}, ClientIds: []string{}, @@ -294,18 +321,12 @@ func (a *App) UploadFiles(teamId string, channelId string, userId string, fileHe thumbnailPathList := []string{} imageDataList := [][]byte{} - for i, fileHeader := range fileHeaders { - file, fileErr := fileHeader.Open() - if fileErr != nil { - return nil, model.NewAppError("UploadFiles", "api.file.upload_file.bad_parse.app_error", nil, fileErr.Error(), http.StatusBadRequest) - } - defer file.Close() - + for i, file := range files { buf := bytes.NewBuffer(nil) io.Copy(buf, file) data := buf.Bytes() - info, err := a.DoUploadFile(time.Now(), teamId, channelId, userId, fileHeader.Filename, data) + info, err := a.DoUploadFile(time.Now(), teamId, channelId, userId, filenames[i], data) if err != nil { return nil, err } diff --git a/i18n/en.json b/i18n/en.json index 033aa85ea8..dcbcd4c8d6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1368,6 +1368,10 @@ "id": "api.file.upload_file.bad_parse.app_error", "translation": "Unable to upload file. Header cannot be parsed." }, + { + "id": "api.file.upload_file.incorrect_number_of_files.app_error", + "translation": "Unable to upload files. Incorrect number of files specified." + }, { "id": "api.file.upload_file.large_image.app_error", "translation": "File above maximum dimensions could not be uploaded: {{.Filename}}" diff --git a/model/client4.go b/model/client4.go index 962b816bb2..4772d38b3a 100644 --- a/model/client4.go +++ b/model/client4.go @@ -1915,7 +1915,8 @@ func (c *Client4) DoPostAction(postId, actionId string) (bool, *Response) { // File Section -// UploadFile will upload a file to a channel, to be later attached to a post. +// UploadFile will upload a file to a channel using a multipart request, to be later attached to a post. +// This method is functionally equivalent to Client4.UploadFileAsRequestBody. func (c *Client4) UploadFile(data []byte, channelId string, filename string) (*FileUploadResponse, *Response) { body := &bytes.Buffer{} writer := multipart.NewWriter(body) @@ -1939,6 +1940,12 @@ func (c *Client4) UploadFile(data []byte, channelId string, filename string) (*F return c.DoUploadFile(c.GetFilesRoute(), body.Bytes(), writer.FormDataContentType()) } +// UploadFileAsRequestBody will upload a file to a channel as the body of a request, to be later attached +// to a post. This method is functionally equivalent to Client4.UploadFile. +func (c *Client4) UploadFileAsRequestBody(data []byte, channelId string, filename string) (*FileUploadResponse, *Response) { + return c.DoUploadFile(c.GetFilesRoute()+fmt.Sprintf("?channel_id=%v&filename=%v", url.QueryEscape(channelId), url.QueryEscape(filename)), data, http.DetectContentType(data)) +} + // GetFile gets the bytes for a file by id. func (c *Client4) GetFile(fileId string) ([]byte, *Response) { if r, err := c.DoApiGet(c.GetFileRoute(fileId), ""); err != nil { From 19a5d0047dbe5e4d7f4675893bfee125d3a12312 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 20 Feb 2018 12:50:10 -0600 Subject: [PATCH 22/42] MM-8710: Web Hub optimizations (#8293) * webhub optimizations * test fix * minor fix * big perf improvement to ToJson after precomputing * fix hub connection count --- app/web_hub.go | 133 ++++++++++++++++++++------------ model/websocket_message.go | 25 ++++++ model/websocket_message_test.go | 48 ++++++++++++ 3 files changed, 156 insertions(+), 50 deletions(-) diff --git a/app/web_hub.go b/app/web_hub.go index eeae13e09b..c1c8cb7bb1 100644 --- a/app/web_hub.go +++ b/app/web_hub.go @@ -30,7 +30,6 @@ type Hub struct { // See https://github.com/mattermost/mattermost-server/pull/7281 connectionCount int64 app *App - connections []*WebConn connectionIndex int register chan *WebConn unregister chan *WebConn @@ -47,7 +46,6 @@ func (a *App) NewWebHub() *Hub { app: a, register: make(chan *WebConn, 1), unregister: make(chan *WebConn, 1), - connections: make([]*WebConn, 0, model.SESSION_CACHE_SIZE), broadcast: make(chan *model.WebSocketEvent, BROADCAST_QUEUE_SIZE), stop: make(chan struct{}), didStop: make(chan struct{}), @@ -170,8 +168,14 @@ func (a *App) Publish(message *model.WebSocketEvent) { } func (a *App) PublishSkipClusterSend(message *model.WebSocketEvent) { - for _, hub := range a.Hubs { - hub.Broadcast(message) + if message.Broadcast.UserId != "" { + if len(a.Hubs) != 0 { + a.GetHubForUserId(message.Broadcast.UserId).Broadcast(message) + } + } else { + for _, hub := range a.Hubs { + hub.Broadcast(message) + } } } @@ -362,80 +366,53 @@ func (h *Hub) Start() { var doRecover func() doStart = func() { - h.goroutineId = getGoroutineId() l4g.Debug("Hub for index %v is starting with goroutine %v", h.connectionIndex, h.goroutineId) + connections := newHubConnectionIndex() + for { select { case webCon := <-h.register: - h.connections = append(h.connections, webCon) - atomic.StoreInt64(&h.connectionCount, int64(len(h.connections))) - + connections.Add(webCon) + atomic.StoreInt64(&h.connectionCount, int64(len(connections.All()))) case webCon := <-h.unregister: - userId := webCon.UserId + connections.Remove(webCon) - found := false - indexToDel := -1 - for i, webConCandidate := range h.connections { - if webConCandidate == webCon { - indexToDel = i - continue - } - if userId == webConCandidate.UserId { - found = true - if indexToDel != -1 { - break - } - } - } - - if indexToDel != -1 { - // Delete the webcon we are unregistering - h.connections[indexToDel] = h.connections[len(h.connections)-1] - h.connections = h.connections[:len(h.connections)-1] - } - - if len(userId) == 0 { + if len(webCon.UserId) == 0 { continue } - if !found { + if len(connections.ForUser(webCon.UserId)) == 0 { h.app.Go(func() { - h.app.SetStatusOffline(userId, false) + h.app.SetStatusOffline(webCon.UserId, false) }) } - case userId := <-h.invalidateUser: - for _, webCon := range h.connections { - if webCon.UserId == userId { - webCon.InvalidateCache() - } + for _, webCon := range connections.ForUser(userId) { + webCon.InvalidateCache() } - case msg := <-h.broadcast: - for _, webCon := range h.connections { + candidates := connections.All() + if msg.Broadcast.UserId != "" { + candidates = connections.ForUser(msg.Broadcast.UserId) + } + msg.PrecomputeJSON() + for _, webCon := range candidates { if webCon.ShouldSendEvent(msg) { select { case webCon.Send <- msg: default: l4g.Error(fmt.Sprintf("webhub.broadcast: cannot send, closing websocket for userId=%v", webCon.UserId)) close(webCon.Send) - for i, webConCandidate := range h.connections { - if webConCandidate == webCon { - h.connections[i] = h.connections[len(h.connections)-1] - h.connections = h.connections[:len(h.connections)-1] - break - } - } + connections.Remove(webCon) } } } - case <-h.stop: userIds := make(map[string]bool) - for _, webCon := range h.connections { + for _, webCon := range connections.All() { userIds[webCon.UserId] = true webCon.Close() } @@ -444,7 +421,6 @@ func (h *Hub) Start() { h.app.SetStatusOffline(userId, false) } - h.connections = make([]*WebConn, 0, model.SESSION_CACHE_SIZE) h.ExplicitStop = true close(h.didStop) @@ -474,3 +450,60 @@ func (h *Hub) Start() { go doRecoverableStart() } + +type hubConnectionIndexIndexes struct { + connections int + connectionsByUserId int +} + +// hubConnectionIndex provides fast addition, removal, and iteration of web connections. +type hubConnectionIndex struct { + connections []*WebConn + connectionsByUserId map[string][]*WebConn + connectionIndexes map[*WebConn]*hubConnectionIndexIndexes +} + +func newHubConnectionIndex() *hubConnectionIndex { + return &hubConnectionIndex{ + connections: make([]*WebConn, 0, model.SESSION_CACHE_SIZE), + connectionsByUserId: make(map[string][]*WebConn), + connectionIndexes: make(map[*WebConn]*hubConnectionIndexIndexes), + } +} + +func (i *hubConnectionIndex) Add(wc *WebConn) { + i.connections = append(i.connections, wc) + i.connectionsByUserId[wc.UserId] = append(i.connectionsByUserId[wc.UserId], wc) + i.connectionIndexes[wc] = &hubConnectionIndexIndexes{ + connections: len(i.connections) - 1, + connectionsByUserId: len(i.connectionsByUserId[wc.UserId]) - 1, + } +} + +func (i *hubConnectionIndex) Remove(wc *WebConn) { + indexes, ok := i.connectionIndexes[wc] + if !ok { + return + } + + last := i.connections[len(i.connections)-1] + i.connections[indexes.connections] = last + i.connections = i.connections[:len(i.connections)-1] + i.connectionIndexes[last].connections = indexes.connections + + userConnections := i.connectionsByUserId[wc.UserId] + last = userConnections[len(userConnections)-1] + userConnections[indexes.connectionsByUserId] = last + i.connectionsByUserId[wc.UserId] = userConnections[:len(userConnections)-1] + i.connectionIndexes[last].connectionsByUserId = indexes.connectionsByUserId + + delete(i.connectionIndexes, wc) +} + +func (i *hubConnectionIndex) ForUser(id string) []*WebConn { + return i.connectionsByUserId[id] +} + +func (i *hubConnectionIndex) All() []*WebConn { + return i.connections +} diff --git a/model/websocket_message.go b/model/websocket_message.go index 0256e400f5..8d1abecfa8 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -5,6 +5,7 @@ package model import ( "encoding/json" + "fmt" "io" ) @@ -58,11 +59,32 @@ type WebsocketBroadcast struct { TeamId string `json:"team_id"` // broadcast only occurs for users in this team } +type precomputedWebSocketEventJSON struct { + Event json.RawMessage + Data json.RawMessage + Broadcast json.RawMessage +} + type WebSocketEvent struct { Event string `json:"event"` Data map[string]interface{} `json:"data"` Broadcast *WebsocketBroadcast `json:"broadcast"` Sequence int64 `json:"seq"` + + precomputedJSON *precomputedWebSocketEventJSON +} + +// PrecomputeJSON precomputes and stores the serialized JSON for all fields other than Sequence. +// This makes ToJson much more efficient when sending the same event to multiple connections. +func (m *WebSocketEvent) PrecomputeJSON() { + event, _ := json.Marshal(m.Event) + data, _ := json.Marshal(m.Data) + broadcast, _ := json.Marshal(m.Broadcast) + m.precomputedJSON = &precomputedWebSocketEventJSON{ + Event: json.RawMessage(event), + Data: json.RawMessage(data), + Broadcast: json.RawMessage(broadcast), + } } func (m *WebSocketEvent) Add(key string, value interface{}) { @@ -83,6 +105,9 @@ func (o *WebSocketEvent) EventType() string { } func (o *WebSocketEvent) ToJson() string { + if o.precomputedJSON != nil { + return fmt.Sprintf(`{"event": %s, "data": %s, "broadcast": %s, "seq": %d}`, o.precomputedJSON.Event, o.precomputedJSON.Data, o.precomputedJSON.Broadcast, o.Sequence) + } b, _ := json.Marshal(o) return string(b) } diff --git a/model/websocket_message_test.go b/model/websocket_message_test.go index 1b75d0f6ea..10404c2998 100644 --- a/model/websocket_message_test.go +++ b/model/websocket_message_test.go @@ -6,6 +6,8 @@ package model import ( "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestWebSocketEvent(t *testing.T) { @@ -54,3 +56,49 @@ func TestWebSocketResponse(t *testing.T) { t.Fatal("Ids do not match") } } + +func TestWebSocketEvent_PrecomputeJSON(t *testing.T) { + event := NewWebSocketEvent(WEBSOCKET_EVENT_POSTED, "foo", "bar", "baz", nil) + event.Sequence = 7 + + before := event.ToJson() + event.PrecomputeJSON() + after := event.ToJson() + + assert.JSONEq(t, before, after) +} + +var stringSink string + +func BenchmarkWebSocketEvent_ToJson(b *testing.B) { + event := NewWebSocketEvent(WEBSOCKET_EVENT_POSTED, "foo", "bar", "baz", nil) + for i := 0; i < 100; i++ { + event.Data[NewId()] = NewId() + } + + b.Run("SerializedNTimes", func(b *testing.B) { + for i := 0; i < b.N; i++ { + stringSink = event.ToJson() + } + }) + + b.Run("PrecomputedNTimes", func(b *testing.B) { + for i := 0; i < b.N; i++ { + event.PrecomputeJSON() + } + }) + + b.Run("PrecomputedAndSerializedNTimes", func(b *testing.B) { + for i := 0; i < b.N; i++ { + event.PrecomputeJSON() + stringSink = event.ToJson() + } + }) + + event.PrecomputeJSON() + b.Run("PrecomputedOnceAndSerializedNTimes", func(b *testing.B) { + for i := 0; i < b.N; i++ { + stringSink = event.ToJson() + } + }) +} From febc129ecaa2a0ca8c6f8deeac654cb296b2b436 Mon Sep 17 00:00:00 2001 From: Jason Blais Date: Tue, 20 Feb 2018 13:56:50 -0500 Subject: [PATCH 23/42] Update en.json (#8332) --- i18n/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i18n/en.json b/i18n/en.json index dcbcd4c8d6..1d0381a69e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3652,7 +3652,7 @@ }, { "id": "app.plugin.disabled.app_error", - "translation": "Plugins have been disabled." + "translation": "Plugins have been disabled. Please check your logs for details." }, { "id": "app.plugin.extract.app_error", From 75d9a3a3b99d0acafb6783a721a35ab1ccdd8d9d Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Tue, 20 Feb 2018 12:49:45 -0800 Subject: [PATCH 24/42] MM-8681 Adding config settings necessary for using CloudFront. (#8307) * Adding config settings nessisary for using CloudFront. * Adding new config settings to diagnostics. --- api4/user.go | 1 + app/config.go | 10 ++++++++++ app/diagnostics.go | 2 ++ app/login.go | 3 +++ config/default.json | 2 ++ i18n/en.json | 4 ++++ model/config.go | 20 ++++++++++++++++++++ utils/config.go | 1 + 8 files changed, 43 insertions(+) diff --git a/api4/user.go b/api4/user.go index cfb2a5b3f1..165e5aa9af 100644 --- a/api4/user.go +++ b/api4/user.go @@ -1076,6 +1076,7 @@ func attachDeviceId(c *Context, w http.ResponseWriter, r *http.Request) { MaxAge: maxAge, Expires: expiresAt, HttpOnly: true, + Domain: c.App.GetCookieDomain(), Secure: secure, } diff --git a/app/config.go b/app/config.go index b4925e8fb7..a9cd84d900 100644 --- a/app/config.go +++ b/app/config.go @@ -12,6 +12,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/url" "runtime/debug" l4g "github.com/alecthomas/log4go" @@ -254,3 +255,12 @@ func (a *App) Desanitize(cfg *model.Config) { cfg.SqlSettings.DataSourceSearchReplicas[i] = actual.SqlSettings.DataSourceSearchReplicas[i] } } + +func (a *App) GetCookieDomain() string { + if *a.Config().ServiceSettings.AllowCookiesForSubdomains { + if siteURL, err := url.Parse(*a.Config().ServiceSettings.SiteURL); err == nil { + return siteURL.Hostname() + } + } + return "" +} diff --git a/app/diagnostics.go b/app/diagnostics.go index 6d83d3a890..12553afc8a 100644 --- a/app/diagnostics.go +++ b/app/diagnostics.go @@ -243,6 +243,8 @@ func (a *App) trackConfig() { "isdefault_image_proxy_type": isDefault(*cfg.ServiceSettings.ImageProxyType, ""), "isdefault_image_proxy_url": isDefault(*cfg.ServiceSettings.ImageProxyURL, ""), "isdefault_image_proxy_options": isDefault(*cfg.ServiceSettings.ImageProxyOptions, ""), + "websocket_url": isDefault(*cfg.ServiceSettings.WebsocketURL, ""), + "allow_cookies_for_subdomains": *cfg.ServiceSettings.AllowCookiesForSubdomains, }) a.SendDiagnostic(TRACK_CONFIG_TEAM, map[string]interface{}{ diff --git a/app/login.go b/app/login.go index ecc0f0163f..e01566bcdb 100644 --- a/app/login.go +++ b/app/login.go @@ -113,6 +113,7 @@ func (a *App) DoLogin(w http.ResponseWriter, r *http.Request, user *model.User, secure = true } + domain := a.GetCookieDomain() expiresAt := time.Unix(model.GetMillis()/1000+int64(maxAge), 0) sessionCookie := &http.Cookie{ Name: model.SESSION_COOKIE_TOKEN, @@ -121,6 +122,7 @@ func (a *App) DoLogin(w http.ResponseWriter, r *http.Request, user *model.User, MaxAge: maxAge, Expires: expiresAt, HttpOnly: true, + Domain: domain, Secure: secure, } @@ -130,6 +132,7 @@ func (a *App) DoLogin(w http.ResponseWriter, r *http.Request, user *model.User, Path: "/", MaxAge: maxAge, Expires: expiresAt, + Domain: domain, Secure: secure, } diff --git a/config/default.json b/config/default.json index 934635cb9f..52413215eb 100644 --- a/config/default.json +++ b/config/default.json @@ -1,6 +1,7 @@ { "ServiceSettings": { "SiteURL": "http://localhost:8065", + "WebsocketURL": "", "LicenseFileLocation": "", "ListenAddress": ":8065", "ConnectionSecurity": "", @@ -32,6 +33,7 @@ "EnforceMultifactorAuthentication": false, "EnableUserAccessTokens": false, "AllowCorsFrom": "", + "AllowCookiesForSubdomains": false, "SessionLengthWebInDays": 30, "SessionLengthMobileInDays": 30, "SessionLengthSSOInDays": 30, diff --git a/i18n/en.json b/i18n/en.json index 1d0381a69e..3911516f1d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -4962,6 +4962,10 @@ "id": "model.config.is_valid.site_url.app_error", "translation": "Site URL must be a valid URL and start with http:// or https://" }, + { + "id": "model.config.is_valid.websocket_url.app_error", + "translation": "Websocket URL must be a valid URL and start with ws:// or wss://" + }, { "id": "model.config.is_valid.site_url_email_batching.app_error", "translation": "Unable to enable email batching when SiteURL isn't set." diff --git a/model/config.go b/model/config.go index 898099d121..1b916fe13a 100644 --- a/model/config.go +++ b/model/config.go @@ -165,6 +165,7 @@ const ( type ServiceSettings struct { SiteURL *string + WebsocketURL *string LicenseFileLocation *string ListenAddress *string ConnectionSecurity *string @@ -196,6 +197,7 @@ type ServiceSettings struct { EnforceMultifactorAuthentication *bool EnableUserAccessTokens *bool AllowCorsFrom *string + AllowCookiesForSubdomains *bool SessionLengthWebInDays *int SessionLengthMobileInDays *int SessionLengthSSOInDays *int @@ -232,6 +234,10 @@ func (s *ServiceSettings) SetDefaults() { s.SiteURL = NewString(SERVICE_SETTINGS_DEFAULT_SITE_URL) } + if s.WebsocketURL == nil { + s.WebsocketURL = NewString("") + } + if s.LicenseFileLocation == nil { s.LicenseFileLocation = NewString("") } @@ -388,6 +394,10 @@ func (s *ServiceSettings) SetDefaults() { s.AllowCorsFrom = NewString(SERVICE_SETTINGS_DEFAULT_ALLOW_CORS_FROM) } + if s.AllowCookiesForSubdomains == nil { + s.AllowCookiesForSubdomains = NewBool(false) + } + if s.WebserverMode == nil { s.WebserverMode = NewString("gzip") } else if *s.WebserverMode == "regular" { @@ -1778,6 +1788,10 @@ func (o *Config) IsValid() *AppError { return NewAppError("Config.IsValid", "model.config.is_valid.cluster_email_batching.app_error", nil, "", http.StatusBadRequest) } + if len(*o.ServiceSettings.SiteURL) == 0 && *o.ServiceSettings.AllowCookiesForSubdomains { + return NewAppError("Config.IsValid", "Allowing cookies for subdomains requires SiteURL to be set.", nil, "", http.StatusBadRequest) + } + if err := o.TeamSettings.isValid(); err != nil { return err } @@ -2085,6 +2099,12 @@ func (ss *ServiceSettings) isValid() *AppError { } } + if len(*ss.WebsocketURL) != 0 { + if _, err := url.ParseRequestURI(*ss.WebsocketURL); err != nil { + return NewAppError("Config.IsValid", "model.config.is_valid.websocket_url.app_error", nil, "", http.StatusBadRequest) + } + } + if len(*ss.ListenAddress) == 0 { return NewAppError("Config.IsValid", "model.config.is_valid.listen_address.app_error", nil, "", http.StatusBadRequest) } diff --git a/utils/config.go b/utils/config.go index a855733a75..0d3047c5d4 100644 --- a/utils/config.go +++ b/utils/config.go @@ -353,6 +353,7 @@ func GenerateClientConfig(c *model.Config, diagnosticId string, license *model.L props["BuildEnterpriseReady"] = model.BuildEnterpriseReady props["SiteURL"] = strings.TrimRight(*c.ServiceSettings.SiteURL, "/") + props["WebsocketURL"] = strings.TrimRight(*c.ServiceSettings.WebsocketURL, "/") props["SiteName"] = c.TeamSettings.SiteName props["EnableTeamCreation"] = strconv.FormatBool(c.TeamSettings.EnableTeamCreation) props["EnableUserCreation"] = strconv.FormatBool(c.TeamSettings.EnableUserCreation) From 342d05bcfcd8de642b254fe2316c2562482e39f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Wed, 21 Feb 2018 14:34:03 +0100 Subject: [PATCH 25/42] Adding saml migration command (#8263) * Adding saml migration command * Texts fixed --- cmd/platform/user.go | 189 ++++++++++++++++++++++++++----- einterfaces/account_migration.go | 3 +- i18n/en.json | 12 ++ 3 files changed, 176 insertions(+), 28 deletions(-) diff --git a/cmd/platform/user.go b/cmd/platform/user.go index 0913609f15..e2a8c9748c 100644 --- a/cmd/platform/user.go +++ b/cmd/platform/user.go @@ -3,9 +3,12 @@ package main import ( + "encoding/json" "errors" "fmt" + "io/ioutil" + l4g "github.com/alecthomas/log4go" "github.com/mattermost/mattermost-server/app" "github.com/mattermost/mattermost-server/model" "github.com/spf13/cobra" @@ -87,25 +90,41 @@ var deleteAllUsersCmd = &cobra.Command{ } var migrateAuthCmd = &cobra.Command{ - Use: "migrate_auth [from_auth] [to_auth] [match_field]", - Short: "Mass migrate user accounts authentication type", - Long: `Migrates accounts from one authentication provider to another. For example, you can upgrade your authentication provider from email to ldap. + Use: "migrate_auth [from_auth] [to_auth] [migration-options]", + Short: "Mass migrate user accounts authentication type", + Long: `Migrates accounts from one authentication provider to another. For example, you can upgrade your authentication provider from email to ldap.`, + Example: " user migrate_auth email saml users.json", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) < 2 { + return errors.New("Auth migration requires at least 2 arguments.") + } -from_auth: - The authentication service to migrate users accounts from. - Supported options: email, gitlab, saml. + toAuth := args[1] -to_auth: - The authentication service to migrate users to. - Supported options: ldap. + if toAuth != "ldap" && toAuth != "saml" { + return errors.New("Invalid to_auth parameter, must be saml or ldap.") + } -match_field: - The field that is guaranteed to be the same in both authentication services. For example, if the users emails are consistent set to email. - Supported options: email, username. + if toAuth == "ldap" && len(args) != 3 { + return errors.New("Ldap migration requires 3 arguments.") + } -Will display any accounts that are not migrated successfully.`, - Example: " user migrate_auth email ladp email", - RunE: migrateAuthCmdF, + autoFlag, _ := cmd.Flags().GetBool("auto") + + if toAuth == "saml" && autoFlag { + if len(args) != 2 { + return errors.New("Saml migration requires two arguments when using the --auto flag. See help text for details.") + } + } + + if toAuth == "saml" && !autoFlag { + if len(args) != 3 { + return errors.New("Saml migration requires three arguments when not using the --auto flag. See help text for details.") + } + } + return nil + }, + RunE: migrateAuthCmdF, } var verifyUserCmd = &cobra.Command{ @@ -138,7 +157,69 @@ func init() { deleteAllUsersCmd.Flags().Bool("confirm", false, "Confirm you really want to delete the user and a DB backup has been performed.") - migrateAuthCmd.Flags().Bool("force", false, "Force the migration to occour even if there are duplicates on the LDAP server. Duplicates will not be migrated.") + migrateAuthCmd.Flags().Bool("force", false, "Force the migration to occur even if there are duplicates on the LDAP server. Duplicates will not be migrated. (ldap only)") + migrateAuthCmd.Flags().Bool("auto", false, "Automatically migrate all users. Assumes the usernames and emails are identical between Mattermost and SAML services. (saml only)") + migrateAuthCmd.Flags().Bool("dryRun", false, "Run a simulation of the migration process without changing the database.") + migrateAuthCmd.SetUsageTemplate(`Usage: + platform user migrate_auth [from_auth] [to_auth] [migration-options] [flags] + +Examples: +{{.Example}} + +Arguments: + from_auth: + The authentication service to migrate users accounts from. + Supported options: email, gitlab, ldap, saml. + + to_auth: + The authentication service to migrate users to. + Supported options: ldap, saml. + + migration-options: + Migration specific options, full command help for more information. + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}} +`) + migrateAuthCmd.SetHelpTemplate(`Usage: + platform user migrate_auth [from_auth] [to_auth] [migration-options] [flags] + +Examples: +{{.Example}} + +Arguments: + from_auth: + The authentication service to migrate users accounts from. + Supported options: email, gitlab, ldap, saml. + + to_auth: + The authentication service to migrate users to. + Supported options: ldap, saml. + + migration-options (ldap): + match_field: + The field that is guaranteed to be the same in both authentication services. For example, if the users emails are consistent set to email. + Supported options: email, username. + + migration-options (saml): + users_file: + The path of a json file with the usernames and emails of all users to migrate to SAML. The username and email must be the same that the SAML service provider store. And the email must match with the email in mattermost database. + + Example json content: + { + "usr1@email.com": "usr.one", + "usr2@email.com": "usr.two" + } + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}} +`) userCmd.AddCommand( userActivateCmd, @@ -416,27 +497,25 @@ func deleteAllUsersCommandF(cmd *cobra.Command, args []string) error { } func migrateAuthCmdF(cmd *cobra.Command, args []string) error { + if args[1] == "saml" { + return migrateAuthToSamlCmdF(cmd, args) + } + return migrateAuthToLdapCmdF(cmd, args) +} + +func migrateAuthToLdapCmdF(cmd *cobra.Command, args []string) error { a, err := initDBCommandContextCobra(cmd) if err != nil { return err } - if len(args) != 3 { - return errors.New("Expected three arguments. See help text for details.") - } - fromAuth := args[0] - toAuth := args[1] - matchField := args[2] + matchField := args[1] if len(fromAuth) == 0 || (fromAuth != "email" && fromAuth != "gitlab" && fromAuth != "saml") { return errors.New("Invalid from_auth argument") } - if len(toAuth) == 0 || toAuth != "ldap" { - return errors.New("Invalid to_auth argument") - } - // Email auth in Mattermost system is represented by "" if fromAuth == "email" { fromAuth = "" @@ -447,9 +526,10 @@ func migrateAuthCmdF(cmd *cobra.Command, args []string) error { } forceFlag, _ := cmd.Flags().GetBool("force") + dryRunFlag, _ := cmd.Flags().GetBool("dryRun") if migrate := a.AccountMigration; migrate != nil { - if err := migrate.MigrateToLdap(fromAuth, matchField, forceFlag); err != nil { + if err := migrate.MigrateToLdap(fromAuth, matchField, forceFlag, dryRunFlag); err != nil { return errors.New("Error while migrating users: " + err.Error()) } @@ -459,6 +539,61 @@ func migrateAuthCmdF(cmd *cobra.Command, args []string) error { return nil } +func migrateAuthToSamlCmdF(cmd *cobra.Command, args []string) error { + a, err := initDBCommandContextCobra(cmd) + if err != nil { + return err + } + + dryRunFlag, _ := cmd.Flags().GetBool("dryRun") + autoFlag, _ := cmd.Flags().GetBool("auto") + + matchesFile := "" + matches := map[string]string{} + if !autoFlag { + matchesFile = args[1] + + file, e := ioutil.ReadFile(matchesFile) + if e != nil { + return errors.New("Invalid users file.") + } + if json.Unmarshal(file, &matches) != nil { + return errors.New("Invalid users file.") + } + } + + fromAuth := args[0] + + if len(fromAuth) == 0 || (fromAuth != "email" && fromAuth != "gitlab" && fromAuth != "ldap") { + return errors.New("Invalid from_auth argument") + } + + if autoFlag && !dryRunFlag { + var confirm string + CommandPrettyPrintln("You are about to perform an automatic \"" + fromAuth + " to saml\" migration. This must only be done if your current Mattermost users with " + fromAuth + " auth have the same username and email in your SAML service. Otherwise, provide the usernames and emails from your SAML Service using the \"users file\" without the \"--auto\" option.\n\nDo you want to proceed with automatic migration anyway? (YES/NO):") + fmt.Scanln(&confirm) + + if confirm != "YES" { + return errors.New("ABORTED: You did not answer YES exactly, in all capitals.") + } + } + + // Email auth in Mattermost system is represented by "" + if fromAuth == "email" { + fromAuth = "" + } + + if migrate := a.AccountMigration; migrate != nil { + if err := migrate.MigrateToSaml(fromAuth, matches, autoFlag, dryRunFlag); err != nil { + return errors.New("Error while migrating users: " + err.Error()) + } + l4g.Close() + CommandPrettyPrintln("Sucessfully migrated accounts.") + } + + return nil +} + func verifyUserCmdF(cmd *cobra.Command, args []string) error { a, err := initDBCommandContextCobra(cmd) if err != nil { diff --git a/einterfaces/account_migration.go b/einterfaces/account_migration.go index 0db516d752..ab61544ff2 100644 --- a/einterfaces/account_migration.go +++ b/einterfaces/account_migration.go @@ -6,5 +6,6 @@ package einterfaces import "github.com/mattermost/mattermost-server/model" type AccountMigrationInterface interface { - MigrateToLdap(fromAuthService string, forignUserFieldNameToMatch string, force bool) *model.AppError + MigrateToLdap(fromAuthService string, forignUserFieldNameToMatch string, force bool, dryRun bool) *model.AppError + MigrateToSaml(fromAuthService string, usersMap map[string]string, auto bool, dryRun bool) *model.AppError } diff --git a/i18n/en.json b/i18n/en.json index 3911516f1d..ff866a56d1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -4186,6 +4186,18 @@ "id": "ent.migration.migratetoldap.user_not_found", "translation": "Unable to find user on AD/LDAP server: " }, + { + "id": "ent.migration.migratetosaml.user_not_found_in_users_mapping_file", + "translation": "User not found in the users file." + }, + { + "id": "ent.migration.migratetosaml.username_already_used_by_other_user", + "translation": "Username already used by another Mattermost user." + }, + { + "id": "ent.migration.migratetosaml.email_already_used_by_other_user", + "translation": "Email already used by another SAML user." + }, { "id": "ent.saml.attribute.app_error", "translation": "SAML login was unsuccessful because one of the attributes is incorrect. Please contact your System Administrator." From 659ce8c60012a7d5fd597997b1794ac512b3b155 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Wed, 21 Feb 2018 09:20:10 -0500 Subject: [PATCH 26/42] Change log level of 404s to DEBUG (#8339) --- api4/context.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api4/context.go b/api4/context.go index 82c8d9e6c2..e079428aca 100644 --- a/api4/context.go +++ b/api4/context.go @@ -212,8 +212,10 @@ func (c *Context) LogAuditWithUserId(userId, extraInfo string) { func (c *Context) LogError(err *model.AppError) { - // filter out endless reconnects - if c.Path == "/api/v3/users/websocket" && err.StatusCode == 401 || err.Id == "web.check_browser_compatibility.app_error" { + // Filter out 404s, endless reconnects and browser compatibility errors + if err.StatusCode == http.StatusNotFound || + (c.Path == "/api/v3/users/websocket" && err.StatusCode == 401) || + err.Id == "web.check_browser_compatibility.app_error" { c.LogDebug(err) } else { l4g.Error(utils.TDefault("api.context.log.error"), c.Path, err.Where, err.StatusCode, From 1b3808f3ecf754876168a9342e18c46d49f32ddf Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Thu, 22 Feb 2018 09:00:13 -0500 Subject: [PATCH 27/42] Fix push notification content for file only messages (#8346) --- app/notification.go | 8 ++++++-- app/notification_test.go | 6 ++++++ i18n/en.json | 4 ++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/notification.go b/app/notification.go index 24e84500b0..752e4bb12c 100644 --- a/app/notification.go +++ b/app/notification.go @@ -623,7 +623,9 @@ func (a *App) getPushNotificationMessage(postMessage string, wasMentioned bool, message := "" category := "" - if *a.Config().EmailSettings.PushNotificationContents == model.FULL_NOTIFICATION { + contentsConfig := *a.Config().EmailSettings.PushNotificationContents + + if contentsConfig == model.FULL_NOTIFICATION { category = model.CATEGORY_CAN_REPLY if channelType == model.CHANNEL_DIRECT { @@ -631,7 +633,7 @@ func (a *App) getPushNotificationMessage(postMessage string, wasMentioned bool, } else { message = senderName + userLocale("api.post.send_notifications_and_forget.push_in") + channelName + ": " + model.ClearMentionTags(postMessage) } - } else if *a.Config().EmailSettings.PushNotificationContents == model.GENERIC_NO_CHANNEL_NOTIFICATION { + } else if contentsConfig == model.GENERIC_NO_CHANNEL_NOTIFICATION { if channelType == model.CHANNEL_DIRECT { category = model.CATEGORY_CAN_REPLY @@ -659,6 +661,8 @@ func (a *App) getPushNotificationMessage(postMessage string, wasMentioned bool, if len(postMessage) == 0 && hasFiles { if channelType == model.CHANNEL_DIRECT { message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only_dm") + } else if contentsConfig == model.GENERIC_NO_CHANNEL_NOTIFICATION { + message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only_no_channel") } else { message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only") + channelName } diff --git a/app/notification_test.go b/app/notification_test.go index 43703c0198..5fc1d152c2 100644 --- a/app/notification_test.go +++ b/app/notification_test.go @@ -1414,6 +1414,12 @@ func TestGetPushNotificationMessage(t *testing.T) { ExpectedMessage: "user uploaded one or more files in a direct message", ExpectedCategory: model.CATEGORY_CAN_REPLY, }, + "only files without channel, public channel": { + HasFiles: true, + PushNotificationContents: model.GENERIC_NO_CHANNEL_NOTIFICATION, + ChannelType: model.CHANNEL_OPEN, + ExpectedMessage: "user uploaded one or more files", + }, } { t.Run(name, func(t *testing.T) { locale := tc.Locale diff --git a/i18n/en.json b/i18n/en.json index ff866a56d1..37639ba60d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1814,6 +1814,10 @@ "id": "api.post.send_notifications_and_forget.push_image_only", "translation": " uploaded one or more files in " }, + { + "id": "api.post.send_notifications_and_forget.push_image_only_no_channel", + "translation": " uploaded one or more files" + }, { "id": "api.post.send_notifications_and_forget.push_image_only_dm", "translation": " uploaded one or more files in a direct message" From d44ef7ea67324072f3fbf1230c77f0ee0b4ac8bd Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 22 Feb 2018 18:23:32 -0600 Subject: [PATCH 28/42] Remove global site url (#8343) * remove global site url * missed one * revert mysterious change --- api/user.go | 6 +++--- api4/user.go | 3 +-- app/app.go | 2 ++ app/config.go | 7 ++++++- app/email.go | 2 +- app/ldap.go | 4 ++-- app/notification.go | 4 ++-- app/oauth.go | 8 ++++---- app/team.go | 2 +- app/user.go | 18 +++++++++--------- utils/api.go | 2 +- utils/config.go | 9 --------- 12 files changed, 32 insertions(+), 35 deletions(-) diff --git a/api/user.go b/api/user.go index ad4f12ef3c..14cc881dc9 100644 --- a/api/user.go +++ b/api/user.go @@ -752,7 +752,7 @@ func sendPasswordReset(c *Context, w http.ResponseWriter, r *http.Request) { return } - if sent, err := c.App.SendPasswordReset(email, utils.GetSiteURL()); err != nil { + if sent, err := c.App.SendPasswordReset(email, c.App.GetSiteURL()); err != nil { c.Err = err return } else if sent { @@ -1046,7 +1046,7 @@ func updateMfa(c *Context, w http.ResponseWriter, r *http.Request) { return } - if err := c.App.SendMfaChangeEmail(user.Email, activate, user.Locale, utils.GetSiteURL()); err != nil { + if err := c.App.SendMfaChangeEmail(user.Email, activate, user.Locale, c.App.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) @@ -1180,7 +1180,7 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { } c.LogAuditWithUserId(user.Id, "Revoked all sessions for user") c.App.Go(func() { - if err := c.App.SendSignInChangeEmail(user.Email, strings.Title(model.USER_AUTH_SERVICE_SAML)+" SSO", user.Locale, utils.GetSiteURL()); err != nil { + if err := c.App.SendSignInChangeEmail(user.Email, strings.Title(model.USER_AUTH_SERVICE_SAML)+" SSO", user.Locale, c.App.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) diff --git a/api4/user.go b/api4/user.go index 165e5aa9af..f82a6e3d52 100644 --- a/api4/user.go +++ b/api4/user.go @@ -13,7 +13,6 @@ import ( "github.com/mattermost/mattermost-server/app" "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/store" - "github.com/mattermost/mattermost-server/utils" ) func (api *API) InitUser() { @@ -894,7 +893,7 @@ func sendPasswordReset(c *Context, w http.ResponseWriter, r *http.Request) { return } - if sent, err := c.App.SendPasswordReset(email, utils.GetSiteURL()); err != nil { + if sent, err := c.App.SendPasswordReset(email, c.App.GetSiteURL()); err != nil { c.Err = err return } else if sent { diff --git a/app/app.go b/app/app.go index 636f0a4285..26aed4c734 100644 --- a/app/app.go +++ b/app/app.go @@ -63,6 +63,8 @@ type App struct { clientLicenseValue atomic.Value licenseListeners map[string]func() + siteURL string + newStore func() store.Store htmlTemplateWatcher *utils.HTMLTemplateWatcher diff --git a/app/config.go b/app/config.go index a9cd84d900..35a0c9a3f4 100644 --- a/app/config.go +++ b/app/config.go @@ -14,6 +14,7 @@ import ( "fmt" "net/url" "runtime/debug" + "strings" l4g "github.com/alecthomas/log4go" @@ -54,7 +55,7 @@ func (a *App) LoadConfig(configFile string) *model.AppError { a.config.Store(cfg) - utils.SetSiteURL(*cfg.ServiceSettings.SiteURL) + a.siteURL = strings.TrimRight(*cfg.ServiceSettings.SiteURL, "/") a.InvokeConfigListeners(old, cfg) return nil @@ -264,3 +265,7 @@ func (a *App) GetCookieDomain() string { } return "" } + +func (a *App) GetSiteURL() string { + return a.siteURL +} diff --git a/app/email.go b/app/email.go index 54a272a3b3..8ee3e79e2c 100644 --- a/app/email.go +++ b/app/email.go @@ -191,7 +191,7 @@ func (a *App) SendUserAccessTokenAddedEmail(email, locale string) *model.AppErro bodyPage := a.NewEmailTemplate("password_change_body", locale) bodyPage.Props["Title"] = T("api.templates.user_access_token_body.title") bodyPage.Html["Info"] = utils.TranslateAsHtml(T, "api.templates.user_access_token_body.info", - map[string]interface{}{"SiteName": a.ClientConfig()["SiteName"], "SiteURL": utils.GetSiteURL()}) + map[string]interface{}{"SiteName": a.ClientConfig()["SiteName"], "SiteURL": a.GetSiteURL()}) if err := a.SendMail(email, subject, bodyPage.Render()); err != nil { return model.NewAppError("SendUserAccessTokenAddedEmail", "api.user.send_user_access_token.error", nil, err.Error(), http.StatusInternalServerError) diff --git a/app/ldap.go b/app/ldap.go index 179529c520..ff7a5ed218 100644 --- a/app/ldap.go +++ b/app/ldap.go @@ -67,7 +67,7 @@ func (a *App) SwitchEmailToLdap(email, password, code, ldapId, ldapPassword stri } a.Go(func() { - if err := a.SendSignInChangeEmail(user.Email, "AD/LDAP", user.Locale, utils.GetSiteURL()); err != nil { + if err := a.SendSignInChangeEmail(user.Email, "AD/LDAP", user.Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) @@ -113,7 +113,7 @@ func (a *App) SwitchLdapToEmail(ldapPassword, code, email, newPassword string) ( T := utils.GetUserTranslations(user.Locale) a.Go(func() { - if err := a.SendSignInChangeEmail(user.Email, T("api.templates.signin_change_email.body.method_email"), user.Locale, utils.GetSiteURL()); err != nil { + if err := a.SendSignInChangeEmail(user.Email, T("api.templates.signin_change_email.body.method_email"), user.Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) diff --git a/app/notification.go b/app/notification.go index 752e4bb12c..8cb63fbafb 100644 --- a/app/notification.go +++ b/app/notification.go @@ -362,7 +362,7 @@ func (a *App) sendNotificationEmail(post *model.Post, user *model.User, channel emailNotificationContentsType = *a.Config().EmailSettings.EmailNotificationContentsType } - teamURL := utils.GetSiteURL() + "/" + team.Name + teamURL := a.GetSiteURL() + "/" + team.Name var bodyText = a.getNotificationEmailBody(user, post, channel, senderName, team.Name, teamURL, emailNotificationContentsType, translateFunc) a.Go(func() { @@ -421,7 +421,7 @@ func (a *App) getNotificationEmailBody(recipient *model.User, post *model.Post, bodyPage = a.NewEmailTemplate("post_body_generic", recipient.Locale) } - bodyPage.Props["SiteURL"] = utils.GetSiteURL() + bodyPage.Props["SiteURL"] = a.GetSiteURL() if teamName != "select_team" { bodyPage.Props["TeamLink"] = teamURL + "/pl/" + post.Id } else { diff --git a/app/oauth.go b/app/oauth.go index 5a66f542e6..b9001ee0b6 100644 --- a/app/oauth.go +++ b/app/oauth.go @@ -527,7 +527,7 @@ func (a *App) CompleteSwitchWithOAuth(service string, userData io.ReadCloser, em } a.Go(func() { - if err := a.SendSignInChangeEmail(user.Email, strings.Title(service)+" SSO", user.Locale, utils.GetSiteURL()); err != nil { + if err := a.SendSignInChangeEmail(user.Email, strings.Title(service)+" SSO", user.Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) @@ -600,7 +600,7 @@ func (a *App) GetAuthorizationCode(w http.ResponseWriter, r *http.Request, servi props["token"] = stateToken.Token state := b64.StdEncoding.EncodeToString([]byte(model.MapToJson(props))) - redirectUri := utils.GetSiteURL() + "/signup/" + service + "/complete" + redirectUri := a.GetSiteURL() + "/signup/" + service + "/complete" authUrl := endpoint + "?response_type=code&client_id=" + clientId + "&redirect_uri=" + url.QueryEscape(redirectUri) + "&state=" + url.QueryEscape(state) @@ -736,7 +736,7 @@ func (a *App) SwitchEmailToOAuth(w http.ResponseWriter, r *http.Request, email, stateProps["email"] = email if service == model.USER_AUTH_SERVICE_SAML { - return utils.GetSiteURL() + "/login/sso/saml?action=" + model.OAUTH_ACTION_EMAIL_TO_SSO + "&email=" + utils.UrlEncode(email), nil + return a.GetSiteURL() + "/login/sso/saml?action=" + model.OAUTH_ACTION_EMAIL_TO_SSO + "&email=" + utils.UrlEncode(email), nil } else { if authUrl, err := a.GetAuthorizationCode(w, r, service, stateProps, ""); err != nil { return "", err @@ -768,7 +768,7 @@ func (a *App) SwitchOAuthToEmail(email, password, requesterId string) (string, * T := utils.GetUserTranslations(user.Locale) a.Go(func() { - if err := a.SendSignInChangeEmail(user.Email, T("api.templates.signin_change_email.body.method_email"), user.Locale, utils.GetSiteURL()); err != nil { + if err := a.SendSignInChangeEmail(user.Email, T("api.templates.signin_change_email.body.method_email"), user.Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) diff --git a/app/team.go b/app/team.go index a15c64c3fa..d8750bfbb9 100644 --- a/app/team.go +++ b/app/team.go @@ -741,7 +741,7 @@ func (a *App) InviteNewUsersToTeam(emailList []string, teamId, senderId string) } nameFormat := *a.Config().TeamSettings.TeammateNameDisplay - a.SendInviteEmails(team, user.GetDisplayName(nameFormat), emailList, utils.GetSiteURL()) + a.SendInviteEmails(team, user.GetDisplayName(nameFormat), emailList, a.GetSiteURL()) return nil } diff --git a/app/user.go b/app/user.go index 69c6d072b3..f915f35cb3 100644 --- a/app/user.go +++ b/app/user.go @@ -106,7 +106,7 @@ func (a *App) CreateUserWithInviteId(user *model.User, inviteId string) (*model. a.AddDirectChannels(team.Id, ruser) - if err := a.SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.Locale, utils.GetSiteURL()); err != nil { + if err := a.SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } @@ -119,7 +119,7 @@ func (a *App) CreateUserAsAdmin(user *model.User) (*model.User, *model.AppError) return nil, err } - if err := a.SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.Locale, utils.GetSiteURL()); err != nil { + if err := a.SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } @@ -143,7 +143,7 @@ func (a *App) CreateUserFromSignup(user *model.User) (*model.User, *model.AppErr return nil, err } - if err := a.SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.Locale, utils.GetSiteURL()); err != nil { + if err := a.SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } @@ -1027,7 +1027,7 @@ func (a *App) UpdateUser(user *model.User, sendNotifications bool) (*model.User, if sendNotifications { if rusers[0].Email != rusers[1].Email { a.Go(func() { - if err := a.SendEmailChangeEmail(rusers[1].Email, rusers[0].Email, rusers[0].Locale, utils.GetSiteURL()); err != nil { + if err := a.SendEmailChangeEmail(rusers[1].Email, rusers[0].Email, rusers[0].Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) @@ -1041,7 +1041,7 @@ func (a *App) UpdateUser(user *model.User, sendNotifications bool) (*model.User, if rusers[0].Username != rusers[1].Username { a.Go(func() { - if err := a.SendChangeUsernameEmail(rusers[1].Username, rusers[0].Username, rusers[0].Email, rusers[0].Locale, utils.GetSiteURL()); err != nil { + if err := a.SendChangeUsernameEmail(rusers[1].Username, rusers[0].Username, rusers[0].Email, rusers[0].Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) @@ -1091,7 +1091,7 @@ func (a *App) UpdateMfa(activate bool, userId, token string) *model.AppError { return } - if err := a.SendMfaChangeEmail(user.Email, activate, user.Locale, utils.GetSiteURL()); err != nil { + if err := a.SendMfaChangeEmail(user.Email, activate, user.Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) @@ -1129,7 +1129,7 @@ func (a *App) UpdatePasswordSendEmail(user *model.User, newPassword, method stri } a.Go(func() { - if err := a.SendPasswordChangeEmail(user.Email, method, user.Locale, utils.GetSiteURL()); err != nil { + if err := a.SendPasswordChangeEmail(user.Email, method, user.Locale, a.GetSiteURL()); err != nil { l4g.Error(err.Error()) } }) @@ -1342,9 +1342,9 @@ func (a *App) SendEmailVerification(user *model.User) *model.AppError { } if _, err := a.GetStatus(user.Id); err != nil { - return a.SendVerifyEmail(user.Email, user.Locale, utils.GetSiteURL(), token.Token) + return a.SendVerifyEmail(user.Email, user.Locale, a.GetSiteURL(), token.Token) } else { - return a.SendEmailChangeVerifyEmail(user.Email, user.Locale, utils.GetSiteURL(), token.Token) + return a.SendEmailChangeVerifyEmail(user.Email, user.Locale, a.GetSiteURL(), token.Token) } } diff --git a/utils/api.go b/utils/api.go index 51524074d7..0f26408298 100644 --- a/utils/api.go +++ b/utils/api.go @@ -52,7 +52,7 @@ func RenderWebError(w http.ResponseWriter, r *http.Request, status int, params u http.Error(w, "", http.StatusInternalServerError) return } - destination := strings.TrimRight(GetSiteURL(), "/") + "/error?" + queryString + "&s=" + base64.URLEncoding.EncodeToString(signature) + destination := "/error?" + queryString + "&s=" + base64.URLEncoding.EncodeToString(signature) if status >= 300 && status < 400 { http.Redirect(w, r, destination, status) diff --git a/utils/config.go b/utils/config.go index 0d3047c5d4..afb2e758cc 100644 --- a/utils/config.go +++ b/utils/config.go @@ -34,15 +34,6 @@ const ( ) var originalDisableDebugLvl l4g.Level = l4g.DEBUG -var siteURL = "" - -func GetSiteURL() string { - return siteURL -} - -func SetSiteURL(url string) { - siteURL = strings.TrimRight(url, "/") -} // FindConfigFile attempts to find an existing configuration file. fileName can be an absolute or // relative path or name such as "/opt/mattermost/config.json" or simply "config.json". An empty From 4211a2bb3ccda5698fc3c284d9f5c468eb575d12 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Fri, 23 Feb 2018 10:08:35 -0500 Subject: [PATCH 29/42] Fall back to constructured URL for OAuth SSO if SiteURL not set (#8352) --- app/oauth.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/oauth.go b/app/oauth.go index b9001ee0b6..630fd3e2d3 100644 --- a/app/oauth.go +++ b/app/oauth.go @@ -600,7 +600,12 @@ func (a *App) GetAuthorizationCode(w http.ResponseWriter, r *http.Request, servi props["token"] = stateToken.Token state := b64.StdEncoding.EncodeToString([]byte(model.MapToJson(props))) - redirectUri := a.GetSiteURL() + "/signup/" + service + "/complete" + siteUrl := a.GetSiteURL() + if strings.TrimSpace(siteUrl) == "" { + siteUrl = GetProtocol(r) + "://" + r.Host + } + + redirectUri := siteUrl + "/signup/" + service + "/complete" authUrl := endpoint + "?response_type=code&client_id=" + clientId + "&redirect_uri=" + url.QueryEscape(redirectUri) + "&state=" + url.QueryEscape(state) From 5fb4b1bc935bd273990779e1d4a680a563784df8 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Fri, 23 Feb 2018 10:55:02 -0500 Subject: [PATCH 30/42] MM-8593 Default APIv3 to disabled for new installs (#8353) * Default APIv3 to disabled for new installs * Add EnableAPIv3 setting to client config * Enable APIv3 for needed tests --- api/apitestlib.go | 1 + config/default.json | 2 +- utils/config.go | 1 + web/web_test.go | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/api/apitestlib.go b/api/apitestlib.go index 47691b2dd0..92a7c0cc8c 100644 --- a/api/apitestlib.go +++ b/api/apitestlib.go @@ -100,6 +100,7 @@ func setupTestHelper(enterprise bool) *TestHelper { *cfg.TeamSettings.MaxUsersPerTeam = 50 *cfg.RateLimitSettings.Enable = false cfg.EmailSettings.SendEmailNotifications = true + *cfg.ServiceSettings.EnableAPIv3 = true }) prevListenAddress := *th.App.Config().ServiceSettings.ListenAddress if testStore != nil { diff --git a/config/default.json b/config/default.json index 52413215eb..cd170adef7 100644 --- a/config/default.json +++ b/config/default.json @@ -22,7 +22,7 @@ "EnableOnlyAdminIntegrations": true, "EnablePostUsernameOverride": false, "EnablePostIconOverride": false, - "EnableAPIv3": true, + "EnableAPIv3": false, "EnableLinkPreviews": false, "EnableTesting": false, "EnableDeveloper": false, diff --git a/utils/config.go b/utils/config.go index afb2e758cc..679e5f62c9 100644 --- a/utils/config.go +++ b/utils/config.go @@ -346,6 +346,7 @@ func GenerateClientConfig(c *model.Config, diagnosticId string, license *model.L props["SiteURL"] = strings.TrimRight(*c.ServiceSettings.SiteURL, "/") props["WebsocketURL"] = strings.TrimRight(*c.ServiceSettings.WebsocketURL, "/") props["SiteName"] = c.TeamSettings.SiteName + props["EnableAPIv3"] = strconv.FormatBool(*c.ServiceSettings.EnableAPIv3) props["EnableTeamCreation"] = strconv.FormatBool(c.TeamSettings.EnableTeamCreation) props["EnableUserCreation"] = strconv.FormatBool(c.TeamSettings.EnableUserCreation) props["EnableOpenServer"] = strconv.FormatBool(*c.TeamSettings.EnableOpenServer) diff --git a/web/web_test.go b/web/web_test.go index 5bc69b6973..373d47103f 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -59,6 +59,7 @@ func Setup() *app.App { a.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableOpenServer = true + *cfg.ServiceSettings.EnableAPIv3 = true }) return a From 55261a2b155c4e3d699fc6bd4255f05fb8258157 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 23 Feb 2018 12:56:32 -0600 Subject: [PATCH 31/42] fix scheduled task race (#8355) --- app/email_batching.go | 18 ++-- model/scheduled_task.go | 97 +++++++-------------- model/scheduled_task_test.go | 163 ++++++----------------------------- 3 files changed, 70 insertions(+), 208 deletions(-) diff --git a/app/email_batching.go b/app/email_batching.go index 2a33d7d3e1..07adda6742 100644 --- a/app/email_batching.go +++ b/app/email_batching.go @@ -7,6 +7,7 @@ import ( "fmt" "html/template" "strconv" + "sync" "time" "github.com/mattermost/mattermost-server/model" @@ -57,6 +58,8 @@ type EmailBatchingJob struct { app *App newNotifications chan *batchedNotification pendingNotifications map[string][]*batchedNotification + task *model.ScheduledTask + taskMutex sync.Mutex } func NewEmailBatchingJob(a *App, bufferSize int) *EmailBatchingJob { @@ -68,12 +71,17 @@ func NewEmailBatchingJob(a *App, bufferSize int) *EmailBatchingJob { } func (job *EmailBatchingJob) Start() { - if task := model.GetTaskByName(EMAIL_BATCHING_TASK_NAME); task != nil { - task.Cancel() - } - l4g.Debug(utils.T("api.email_batching.start.starting"), *job.app.Config().EmailSettings.EmailBatchingInterval) - model.CreateRecurringTask(EMAIL_BATCHING_TASK_NAME, job.CheckPendingEmails, time.Duration(*job.app.Config().EmailSettings.EmailBatchingInterval)*time.Second) + newTask := model.CreateRecurringTask(EMAIL_BATCHING_TASK_NAME, job.CheckPendingEmails, time.Duration(*job.app.Config().EmailSettings.EmailBatchingInterval)*time.Second) + + job.taskMutex.Lock() + oldTask := job.task + job.task = newTask + job.taskMutex.Unlock() + + if oldTask != nil { + oldTask.Cancel() + } } func (job *EmailBatchingJob) Add(user *model.User, post *model.Post, team *model.Team) bool { diff --git a/model/scheduled_task.go b/model/scheduled_task.go index 453828bd2e..f3529dedb7 100644 --- a/model/scheduled_task.go +++ b/model/scheduled_task.go @@ -5,7 +5,6 @@ package model import ( "fmt" - "sync" "time" ) @@ -15,89 +14,57 @@ type ScheduledTask struct { Name string `json:"name"` Interval time.Duration `json:"interval"` Recurring bool `json:"recurring"` - function TaskFunc - timer *time.Timer -} - -var taskMutex = sync.Mutex{} -var tasks = make(map[string]*ScheduledTask) - -func addTask(task *ScheduledTask) { - taskMutex.Lock() - defer taskMutex.Unlock() - tasks[task.Name] = task -} - -func removeTaskByName(name string) { - taskMutex.Lock() - defer taskMutex.Unlock() - delete(tasks, name) -} - -func GetTaskByName(name string) *ScheduledTask { - taskMutex.Lock() - defer taskMutex.Unlock() - if task, ok := tasks[name]; ok { - return task - } - return nil -} - -func GetAllTasks() *map[string]*ScheduledTask { - taskMutex.Lock() - defer taskMutex.Unlock() - return &tasks + function func() + cancel chan struct{} + cancelled chan struct{} } func CreateTask(name string, function TaskFunc, timeToExecution time.Duration) *ScheduledTask { - task := &ScheduledTask{ - Name: name, - Interval: timeToExecution, - Recurring: false, - function: function, - } - - taskRunner := func() { - go task.function() - removeTaskByName(task.Name) - } - - task.timer = time.AfterFunc(timeToExecution, taskRunner) - - addTask(task) - - return task + return createTask(name, function, timeToExecution, false) } func CreateRecurringTask(name string, function TaskFunc, interval time.Duration) *ScheduledTask { + return createTask(name, function, interval, true) +} + +func createTask(name string, function TaskFunc, interval time.Duration, recurring bool) *ScheduledTask { task := &ScheduledTask{ Name: name, Interval: interval, - Recurring: true, + Recurring: recurring, function: function, + cancel: make(chan struct{}), + cancelled: make(chan struct{}), } - taskRecurer := func() { - go task.function() - task.timer.Reset(task.Interval) - } + go func() { + defer close(task.cancelled) - task.timer = time.AfterFunc(interval, taskRecurer) + ticker := time.NewTicker(interval) + defer func() { + ticker.Stop() + }() - addTask(task) + for { + select { + case <-ticker.C: + function() + case <-task.cancel: + return + } + + if !task.Recurring { + break + } + } + }() return task } func (task *ScheduledTask) Cancel() { - task.timer.Stop() - removeTaskByName(task.Name) -} - -// Executes the task immediatly. A recurring task will be run regularally after interval. -func (task *ScheduledTask) Execute() { - task.function() - task.timer.Reset(task.Interval) + close(task.cancel) + <-task.cancelled } func (task *ScheduledTask) String() string { diff --git a/model/scheduled_task_test.go b/model/scheduled_task_test.go index 5af43b1eff..9537a662af 100644 --- a/model/scheduled_task_test.go +++ b/model/scheduled_task_test.go @@ -4,185 +4,72 @@ package model import ( + "sync/atomic" "testing" "time" + + "github.com/stretchr/testify/assert" ) func TestCreateTask(t *testing.T) { TASK_NAME := "Test Task" - TASK_TIME := time.Second * 3 + TASK_TIME := time.Second * 2 - testValue := 0 + executionCount := new(int32) testFunc := func() { - testValue = 1 + atomic.AddInt32(executionCount, 1) } task := CreateTask(TASK_NAME, testFunc, TASK_TIME) - if testValue != 0 { - t.Fatal("Unexpected execuition of task") - } + assert.EqualValues(t, 0, atomic.LoadInt32(executionCount)) time.Sleep(TASK_TIME + time.Second) - if testValue != 1 { - t.Fatal("Task did not execute") - } - - if task.Name != TASK_NAME { - t.Fatal("Bad name") - } - - if task.Interval != TASK_TIME { - t.Fatal("Bad interval") - } - - if task.Recurring { - t.Fatal("should not reccur") - } + assert.EqualValues(t, 1, atomic.LoadInt32(executionCount)) + assert.Equal(t, TASK_NAME, task.Name) + assert.Equal(t, TASK_TIME, task.Interval) + assert.False(t, task.Recurring) } func TestCreateRecurringTask(t *testing.T) { TASK_NAME := "Test Recurring Task" - TASK_TIME := time.Second * 3 + TASK_TIME := time.Second * 2 - testValue := 0 + executionCount := new(int32) testFunc := func() { - testValue += 1 + atomic.AddInt32(executionCount, 1) } task := CreateRecurringTask(TASK_NAME, testFunc, TASK_TIME) - if testValue != 0 { - t.Fatal("Unexpected execuition of task") - } + assert.EqualValues(t, 0, atomic.LoadInt32(executionCount)) time.Sleep(TASK_TIME + time.Second) - if testValue != 1 { - t.Fatal("Task did not execute") - } + assert.EqualValues(t, 1, atomic.LoadInt32(executionCount)) time.Sleep(TASK_TIME) - if testValue != 2 { - t.Fatal("Task did not re-execute") - } - - if task.Name != TASK_NAME { - t.Fatal("Bad name") - } - - if task.Interval != TASK_TIME { - t.Fatal("Bad interval") - } - - if !task.Recurring { - t.Fatal("should reccur") - } + assert.EqualValues(t, 2, atomic.LoadInt32(executionCount)) + assert.Equal(t, TASK_NAME, task.Name) + assert.Equal(t, TASK_TIME, task.Interval) + assert.True(t, task.Recurring) task.Cancel() } func TestCancelTask(t *testing.T) { TASK_NAME := "Test Task" - TASK_TIME := time.Second * 3 + TASK_TIME := time.Second - testValue := 0 + executionCount := new(int32) testFunc := func() { - testValue = 1 + atomic.AddInt32(executionCount, 1) } task := CreateTask(TASK_NAME, testFunc, TASK_TIME) - if testValue != 0 { - t.Fatal("Unexpected execuition of task") - } + assert.EqualValues(t, 0, atomic.LoadInt32(executionCount)) task.Cancel() time.Sleep(TASK_TIME + time.Second) - - if testValue != 0 { - t.Fatal("Unexpected execuition of task") - } -} - -func TestGetAllTasks(t *testing.T) { - doNothing := func() {} - - CreateTask("Task1", doNothing, time.Hour) - CreateTask("Task2", doNothing, time.Second) - CreateRecurringTask("Task3", doNothing, time.Second) - task4 := CreateRecurringTask("Task4", doNothing, time.Second) - - task4.Cancel() - - time.Sleep(time.Second * 3) - - tasks := *GetAllTasks() - if len(tasks) != 2 { - t.Fatal("Wrong number of tasks got: ", len(tasks)) - } - for _, task := range tasks { - if task.Name != "Task1" && task.Name != "Task3" { - t.Fatal("Wrong tasks") - } - } -} - -func TestExecuteTask(t *testing.T) { - TASK_NAME := "Test Task" - TASK_TIME := time.Second * 5 - - testValue := 0 - testFunc := func() { - testValue += 1 - } - - task := CreateTask(TASK_NAME, testFunc, TASK_TIME) - if testValue != 0 { - t.Fatal("Unexpected execuition of task") - } - - task.Execute() - - if testValue != 1 { - t.Fatal("Task did not execute") - } - - time.Sleep(TASK_TIME + time.Second) - - if testValue != 2 { - t.Fatal("Task re-executed") - } -} - -func TestExecuteTaskRecurring(t *testing.T) { - TASK_NAME := "Test Recurring Task" - TASK_TIME := time.Second * 5 - - testValue := 0 - testFunc := func() { - testValue += 1 - } - - task := CreateRecurringTask(TASK_NAME, testFunc, TASK_TIME) - if testValue != 0 { - t.Fatal("Unexpected execuition of task") - } - - time.Sleep(time.Second * 3) - - task.Execute() - if testValue != 1 { - t.Fatal("Task did not execute") - } - - time.Sleep(time.Second * 3) - if testValue != 1 { - t.Fatal("Task should not have executed before 5 seconds") - } - - time.Sleep(time.Second * 3) - - if testValue != 2 { - t.Fatal("Task did not re-execute after forced execution") - } + assert.EqualValues(t, 0, atomic.LoadInt32(executionCount)) } From 838295f82c4bad22bd83a30a5b157dd717f8ceb6 Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Fri, 23 Feb 2018 15:33:36 -0800 Subject: [PATCH 32/42] Fix forward 80 to 443 to not bind on all interfaces. (#8360) --- app/server.go | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/app/server.go b/app/server.go index afa282ad6b..93804a372f 100644 --- a/app/server.go +++ b/app/server.go @@ -171,20 +171,25 @@ func (a *App) StartServer() error { } if *a.Config().ServiceSettings.Forward80To443 { - if *a.Config().ServiceSettings.UseLetsEncrypt { - go http.ListenAndServe(":http", m.HTTPHandler(nil)) + if host, _, err := net.SplitHostPort(addr); err != nil { + l4g.Error("Unable to setup forwarding: " + err.Error()) } else { - go func() { - redirectListener, err := net.Listen("tcp", ":80") - if err != nil { - listener.Close() - l4g.Error("Unable to setup forwarding: " + err.Error()) - return - } - defer redirectListener.Close() + httpListenAddress := net.JoinHostPort(host, "http") - http.Serve(redirectListener, http.HandlerFunc(redirectHTTPToHTTPS)) - }() + if *a.Config().ServiceSettings.UseLetsEncrypt { + go http.ListenAndServe(httpListenAddress, m.HTTPHandler(nil)) + } else { + go func() { + redirectListener, err := net.Listen("tcp", httpListenAddress) + if err != nil { + l4g.Error("Unable to setup forwarding: " + err.Error()) + return + } + defer redirectListener.Close() + + http.Serve(redirectListener, http.HandlerFunc(redirectHTTPToHTTPS)) + }() + } } } From f0f4f68defef1e9360394ba740c57cff7124dc9c Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 26 Feb 2018 01:48:56 -0600 Subject: [PATCH 33/42] add individual platform packaging targets (#8366) --- Makefile | 2 +- build/release.mk | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 366c27057a..490522c069 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build package run stop run-client run-server stop-client stop-server restart restart-server restart-client start-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist setup-mac prepare-enteprise run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows internal-test-web-client vet run-server-for-web-client-tests +.PHONY: build package run stop run-client run-server stop-client stop-server restart restart-server restart-client start-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist setup-mac prepare-enteprise run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-common package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) diff --git a/build/release.mk b/build/release.mk index 5eaee8022d..8bd4f9afda 100644 --- a/build/release.mk +++ b/build/release.mk @@ -20,9 +20,7 @@ build-client: cd $(BUILD_WEBAPP_DIR) && $(MAKE) build -package: - @ echo Packaging mattermost - +package-common: @# Remove any old files rm -Rf $(DIST_ROOT) @@ -59,9 +57,7 @@ endif cp NOTICE.txt $(DIST_PATH) cp README.md $(DIST_PATH) - @# ----- PLATFORM SPECIFIC ----- - - @# Make osx package +package-osx: package-common @# Copy binary ifeq ($(BUILDER_GOOS_GOARCH),"darwin_amd64") cp $(GOPATH)/bin/platform $(DIST_PATH)/bin # from native bin dir, not cross-compiled @@ -73,7 +69,7 @@ endif @# Cleanup rm -f $(DIST_PATH)/bin/platform - @# Make windows package +package-windows: package-common @# Copy binary ifeq ($(BUILDER_GOOS_GOARCH),"windows_amd64") cp $(GOPATH)/bin/platform.exe $(DIST_PATH)/bin # from native bin dir, not cross-compiled @@ -85,7 +81,7 @@ endif @# Cleanup rm -f $(DIST_PATH)/bin/platform.exe - @# Make linux package +package-linux: package-common @# Copy binary ifeq ($(BUILDER_GOOS_GOARCH),"linux_amd64") cp $(GOPATH)/bin/platform $(DIST_PATH)/bin # from native bin dir, not cross-compiled @@ -96,3 +92,5 @@ endif tar -C dist -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-linux-amd64.tar.gz mattermost @# Don't clean up native package so dev machines will have an unzipped package available @#rm -f $(DIST_PATH)/bin/platform + +package: package-linux package-windows package-osx From ae1acbda49e7039cb96289031e4ae057933d5e64 Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Mon, 26 Feb 2018 06:45:35 -0800 Subject: [PATCH 34/42] MM-9698 Fixing Minio with server side encryption. (#8367) * Fixig Minio with server side encryption. * Add png file backend test --- utils/file_backend_s3.go | 25 +++++++++++++++++-------- utils/file_backend_test.go | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/utils/file_backend_s3.go b/utils/file_backend_s3.go index 7ef1508519..8e72272a1b 100644 --- a/utils/file_backend_s3.go +++ b/utils/file_backend_s3.go @@ -138,16 +138,14 @@ func (b *S3FileBackend) WriteFile(f []byte, path string) *model.AppError { return model.NewAppError("WriteFile", "api.file.write_file.s3.app_error", nil, err.Error(), http.StatusInternalServerError) } - options := s3.PutObjectOptions{} - if b.encrypt { - options.UserMetadata["x-amz-server-side-encryption"] = "AES256" + var contentType string + if ext := filepath.Ext(path); model.IsFileExtImage(ext) { + contentType = model.GetImageMimeType(ext) + } else { + contentType = "binary/octet-stream" } - if ext := filepath.Ext(path); model.IsFileExtImage(ext) { - options.ContentType = model.GetImageMimeType(ext) - } else { - options.ContentType = "binary/octet-stream" - } + options := s3PutOptions(b.encrypt, contentType) if _, err = s3Clnt.PutObject(b.bucket, path, bytes.NewReader(f), -1, options); err != nil { return model.NewAppError("WriteFile", "api.file.write_file.s3.app_error", nil, err.Error(), http.StatusInternalServerError) @@ -230,6 +228,17 @@ func (b *S3FileBackend) RemoveDirectory(path string) *model.AppError { return nil } +func s3PutOptions(encrypt bool, contentType string) s3.PutObjectOptions { + options := s3.PutObjectOptions{} + if encrypt { + options.UserMetadata = make(map[string]string) + options.UserMetadata["x-amz-server-side-encryption"] = "AES256" + } + options.ContentType = contentType + + return options +} + func s3CopyMetadata(encrypt bool) map[string]string { metaData := make(map[string]string) metaData["x-amz-server-side-encryption"] = "AES256" diff --git a/utils/file_backend_test.go b/utils/file_backend_test.go index 46f75574ef..2b8e2a527a 100644 --- a/utils/file_backend_test.go +++ b/utils/file_backend_test.go @@ -36,6 +36,14 @@ func TestLocalFileBackendTestSuite(t *testing.T) { } func TestS3FileBackendTestSuite(t *testing.T) { + runBackendTest(t, false) +} + +func TestS3FileBackendTestSuiteWithEncryption(t *testing.T) { + runBackendTest(t, true) +} + +func runBackendTest(t *testing.T, encrypt bool) { s3Host := os.Getenv("CI_HOST") if s3Host == "" { s3Host = "dockerhost" @@ -56,6 +64,7 @@ func TestS3FileBackendTestSuite(t *testing.T) { AmazonS3Bucket: model.MINIO_BUCKET, AmazonS3Endpoint: s3Endpoint, AmazonS3SSL: model.NewBool(false), + AmazonS3SSE: model.NewBool(encrypt), }, }) } @@ -86,6 +95,20 @@ func (s *FileBackendTestSuite) TestReadWriteFile() { s.EqualValues(readString, "test") } +func (s *FileBackendTestSuite) TestReadWriteFileImage() { + b := []byte("testimage") + path := "tests/" + model.NewId() + ".png" + + s.Nil(s.backend.WriteFile(b, path)) + defer s.backend.RemoveFile(path) + + read, err := s.backend.ReadFile(path) + s.Nil(err) + + readString := string(read) + s.EqualValues(readString, "testimage") +} + func (s *FileBackendTestSuite) TestCopyFile() { b := []byte("test") path1 := "tests/" + model.NewId() From 2782602c6e9bed74443210d205d2ddf756aea44f Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Tue, 27 Feb 2018 18:07:31 +0000 Subject: [PATCH 35/42] Temporarily disable flaky TestSendMailUsingConfigAdvanced test (#8379) * Temporarily disable flaky TestSendMailUsingConfigAdvanced test * Remove imports --- utils/mail_test.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/utils/mail_test.go b/utils/mail_test.go index 67d108d45f..068c90c60b 100644 --- a/utils/mail_test.go +++ b/utils/mail_test.go @@ -7,12 +7,6 @@ import ( "strings" "testing" - "net/mail" - - "fmt" - - "github.com/mattermost/mattermost-server/model" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -82,7 +76,7 @@ func TestSendMailUsingConfig(t *testing.T) { } } -func TestSendMailUsingConfigAdvanced(t *testing.T) { +/*func TestSendMailUsingConfigAdvanced(t *testing.T) { cfg, _, err := LoadConfig("config.json") require.Nil(t, err) T = GetUserTranslations("en") @@ -174,4 +168,4 @@ func TestSendMailUsingConfigAdvanced(t *testing.T) { } } } -} +}*/ From 2fba6fa7997066adfbe02e92cd22ea75018f4fe7 Mon Sep 17 00:00:00 2001 From: Fede Date: Tue, 27 Feb 2018 23:53:27 +0100 Subject: [PATCH 36/42] Fix prerequisites checker so 1.10 > 1.9 (#8344) * Fix prerequisites checker so 1.10 > 1.9 * Check prerequisites sorting by dot separator --- scripts/prereq-check.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/prereq-check.sh b/scripts/prereq-check.sh index 1c9ae8405f..6f29542732 100755 --- a/scripts/prereq-check.sh +++ b/scripts/prereq-check.sh @@ -2,7 +2,7 @@ check_version() { local version=$1 check=$2 - local winner=$(echo -e "$version\n$check" | sed '/^$/d' | sort -nr | head -1) + local winner=$(echo -e "$version\n$check" | sed '/^$/d' | sort -t. -s -k 1,1nr -k 2,2nr -k 3,3nr -k 4,4nr | head -1) [[ "$winner" = "$version" ]] && return 0 return 1 } @@ -46,4 +46,4 @@ DOCKERVERSION=$(docker version --format '{{.Server.Version}}' | sed 's/[a-z-]//g check_prereq 'node' $REQUIREDNODEVERSION $NODEVERSION check_prereq 'npm' $REQUIREDNPMVERSION $NPMVERSION check_prereq 'go' $REQUIREDGOVERSION $GOVERSION -check_prereq 'docker' $REQUIREDDOCKERVERSION $DOCKERVERSION \ No newline at end of file +check_prereq 'docker' $REQUIREDDOCKERVERSION $DOCKERVERSION From 600528e1cfb06978efe6ce325fe2e2e4bb964f78 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 28 Feb 2018 14:07:11 -0600 Subject: [PATCH 37/42] optimize lru purging (#8381) --- utils/lru.go | 113 +++++++++++++++++----------------------------- utils/lru_test.go | 33 +------------- 2 files changed, 43 insertions(+), 103 deletions(-) diff --git a/utils/lru.go b/utils/lru.go index 576331563f..8e896a6dc3 100644 --- a/utils/lru.go +++ b/utils/lru.go @@ -9,15 +9,14 @@ package utils import ( "container/list" - "errors" "sync" "time" ) // Caching Interface type ObjectCache interface { - AddWithExpiresInSecs(key, value interface{}, expireAtSecs int64) bool - AddWithDefaultExpires(key, value interface{}) bool + AddWithExpiresInSecs(key, value interface{}, expireAtSecs int64) + AddWithDefaultExpires(key, value interface{}) Purge() Get(key interface{}) (value interface{}, ok bool) Remove(key interface{}) @@ -32,10 +31,11 @@ type Cache struct { evictList *list.List items map[interface{}]*list.Element lock sync.RWMutex - onEvicted func(key interface{}, value interface{}) name string defaultExpiry int64 invalidateClusterEvent string + currentGeneration int64 + len int } // entry is used to hold a value in the evictList @@ -43,25 +43,16 @@ type entry struct { key interface{} value interface{} expireAtSecs int64 + generation int64 } // New creates an LRU of the given size func NewLru(size int) *Cache { - cache, _ := NewLruWithEvict(size, nil) - return cache -} - -func NewLruWithEvict(size int, onEvicted func(key interface{}, value interface{})) (*Cache, error) { - if size <= 0 { - return nil, errors.New(T("utils.iru.with_evict")) - } - c := &Cache{ + return &Cache{ size: size, evictList: list.New(), items: make(map[interface{}]*list.Element, size), - onEvicted: onEvicted, } - return c, nil } func NewLruWithParams(size int, name string, defaultExpiry int64, invalidateClusterEvent string) *Cache { @@ -77,26 +68,19 @@ func (c *Cache) Purge() { c.lock.Lock() defer c.lock.Unlock() - if c.onEvicted != nil { - for k, v := range c.items { - c.onEvicted(k, v.Value) - } - } - - c.evictList = list.New() - c.items = make(map[interface{}]*list.Element, c.size) + c.len = 0 + c.currentGeneration++ } -func (c *Cache) Add(key, value interface{}) bool { - return c.AddWithExpiresInSecs(key, value, 0) +func (c *Cache) Add(key, value interface{}) { + c.AddWithExpiresInSecs(key, value, 0) } -func (c *Cache) AddWithDefaultExpires(key, value interface{}) bool { - return c.AddWithExpiresInSecs(key, value, c.defaultExpiry) +func (c *Cache) AddWithDefaultExpires(key, value interface{}) { + c.AddWithExpiresInSecs(key, value, c.defaultExpiry) } -// Add adds a value to the cache. Returns true if an eviction occurred. -func (c *Cache) AddWithExpiresInSecs(key, value interface{}, expireAtSecs int64) bool { +func (c *Cache) AddWithExpiresInSecs(key, value interface{}, expireAtSecs int64) { c.lock.Lock() defer c.lock.Unlock() @@ -107,45 +91,46 @@ func (c *Cache) AddWithExpiresInSecs(key, value interface{}, expireAtSecs int64) // Check for existing item if ent, ok := c.items[key]; ok { c.evictList.MoveToFront(ent) - ent.Value.(*entry).value = value - ent.Value.(*entry).expireAtSecs = expireAtSecs - return false + e := ent.Value.(*entry) + e.value = value + e.expireAtSecs = expireAtSecs + if e.generation != c.currentGeneration { + e.generation = c.currentGeneration + c.len++ + } + return } // Add new item - ent := &entry{key, value, expireAtSecs} + ent := &entry{key, value, expireAtSecs, c.currentGeneration} entry := c.evictList.PushFront(ent) c.items[key] = entry + c.len++ - evict := c.evictList.Len() > c.size - // Verify size not exceeded - if evict { - c.removeOldest() + if c.evictList.Len() > c.size { + c.removeElement(c.evictList.Back()) } - return evict } -// Get looks up a key's value from the cache. func (c *Cache) Get(key interface{}) (value interface{}, ok bool) { c.lock.Lock() defer c.lock.Unlock() if ent, ok := c.items[key]; ok { + e := ent.Value.(*entry) - if ent.Value.(*entry).expireAtSecs > 0 { - if (time.Now().UnixNano() / int64(time.Second)) > ent.Value.(*entry).expireAtSecs { - c.removeElement(ent) - return nil, false - } + if e.generation != c.currentGeneration || (e.expireAtSecs > 0 && (time.Now().UnixNano()/int64(time.Second)) > e.expireAtSecs) { + c.removeElement(ent) + return nil, false } c.evictList.MoveToFront(ent) return ent.Value.(*entry).value, true } - return + + return nil, false } -// Remove removes the provided key from the cache. func (c *Cache) Remove(key interface{}) { c.lock.Lock() defer c.lock.Unlock() @@ -155,25 +140,19 @@ func (c *Cache) Remove(key interface{}) { } } -// RemoveOldest removes the oldest item from the cache. -func (c *Cache) RemoveOldest() { - c.lock.Lock() - defer c.lock.Unlock() - c.removeOldest() -} - // Keys returns a slice of the keys in the cache, from oldest to newest. func (c *Cache) Keys() []interface{} { c.lock.RLock() defer c.lock.RUnlock() - keys := make([]interface{}, len(c.items)) - ent := c.evictList.Back() + keys := make([]interface{}, c.len) i := 0 - for ent != nil { - keys[i] = ent.Value.(*entry).key - ent = ent.Prev() - i++ + for ent := c.evictList.Back(); ent != nil; ent = ent.Prev() { + e := ent.Value.(*entry) + if e.generation == c.currentGeneration { + keys[i] = e.key + i++ + } } return keys @@ -183,7 +162,7 @@ func (c *Cache) Keys() []interface{} { func (c *Cache) Len() int { c.lock.RLock() defer c.lock.RUnlock() - return c.evictList.Len() + return c.len } func (c *Cache) Name() string { @@ -194,20 +173,12 @@ func (c *Cache) GetInvalidateClusterEvent() string { return c.invalidateClusterEvent } -// removeOldest removes the oldest item from the cache. -func (c *Cache) removeOldest() { - ent := c.evictList.Back() - if ent != nil { - c.removeElement(ent) - } -} - // removeElement is used to remove a given list element from the cache func (c *Cache) removeElement(e *list.Element) { c.evictList.Remove(e) kv := e.Value.(*entry) - delete(c.items, kv.key) - if c.onEvicted != nil { - c.onEvicted(kv.key, kv.value) + if kv.generation == c.currentGeneration { + c.len-- } + delete(c.items, kv.key) } diff --git a/utils/lru_test.go b/utils/lru_test.go index 987163cd35..4312515b97 100644 --- a/utils/lru_test.go +++ b/utils/lru_test.go @@ -11,14 +11,7 @@ import "testing" import "time" func TestLRU(t *testing.T) { - evictCounter := 0 - onEvicted := func(k interface{}, v interface{}) { - evictCounter += 1 - } - l, err := NewLruWithEvict(128, onEvicted) - if err != nil { - t.Fatalf("err: %v", err) - } + l := NewLru(128) for i := 0; i < 256; i++ { l.Add(i, i) @@ -27,10 +20,6 @@ func TestLRU(t *testing.T) { t.Fatalf("bad len: %v", l.Len()) } - if evictCounter != 128 { - t.Fatalf("bad evict count: %v", evictCounter) - } - for i, k := range l.Keys() { if v, ok := l.Get(k); !ok || v != k || v != i+128 { t.Fatalf("bad key: %v", k) @@ -73,26 +62,6 @@ func TestLRU(t *testing.T) { } } -// test that Add return true/false if an eviction occurred -func TestLRUAdd(t *testing.T) { - evictCounter := 0 - onEvicted := func(k interface{}, v interface{}) { - evictCounter += 1 - } - - l, err := NewLruWithEvict(1, onEvicted) - if err != nil { - t.Fatalf("err: %v", err) - } - - if l.Add(1, 1) || evictCounter != 0 { - t.Errorf("should not have an eviction") - } - if !l.Add(2, 2) || evictCounter != 1 { - t.Errorf("should have an eviction") - } -} - func TestLRUExpire(t *testing.T) { l := NewLru(128) From d2b70b8671bd267e4b955e3da2ee0670daba5f2c Mon Sep 17 00:00:00 2001 From: George Goldberg Date: Wed, 28 Feb 2018 23:07:09 +0000 Subject: [PATCH 38/42] Simplification of a Channel Members query. (#8345) --- store/sqlstore/channel_store.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/store/sqlstore/channel_store.go b/store/sqlstore/channel_store.go index 75a615aee1..131e5649bc 100644 --- a/store/sqlstore/channel_store.go +++ b/store/sqlstore/channel_store.go @@ -946,9 +946,9 @@ func (s SqlChannelStore) GetAllChannelMembersNotifyPropsForChannel(channelId str var data []allChannelMemberNotifyProps _, err := s.GetReplica().Select(&data, ` - SELECT ChannelMembers.UserId, ChannelMembers.NotifyProps - FROM Channels, ChannelMembers - WHERE Channels.Id = ChannelMembers.ChannelId AND ChannelMembers.ChannelId = :ChannelId`, map[string]interface{}{"ChannelId": channelId}) + SELECT UserId, NotifyProps + FROM ChannelMembers + WHERE ChannelId = :ChannelId`, map[string]interface{}{"ChannelId": channelId}) if err != nil { result.Err = model.NewAppError("SqlChannelStore.GetAllChannelMembersPropsForChannel", "store.sql_channel.get_members.app_error", nil, "channelId="+channelId+", err="+err.Error(), http.StatusInternalServerError) From 6e024c45b50d31c20eb0d509263d3e0f888847de Mon Sep 17 00:00:00 2001 From: Carlos Tadeu Panato Junior Date: Thu, 1 Mar 2018 00:12:11 +0100 Subject: [PATCH 39/42] [PLT-8186] add support for ec2 instance profile authentication (#8243) --- api4/system.go | 31 +++++++++++++++++ api4/system_test.go | 64 +++++++++++++++++++++++++++++++++++ i18n/en.json | 12 +++++++ model/client4.go | 14 ++++++++ utils/file_backend_s3.go | 21 +++++++++++- utils/file_backend_s3_test.go | 32 ++++++++++++++++++ 6 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 utils/file_backend_s3_test.go diff --git a/api4/system.go b/api4/system.go index 2355cb4766..aab65bf208 100644 --- a/api4/system.go +++ b/api4/system.go @@ -29,6 +29,7 @@ func (api *API) InitSystem() { api.BaseRoutes.ApiRoot.Handle("/audits", api.ApiSessionRequired(getAudits)).Methods("GET") api.BaseRoutes.ApiRoot.Handle("/email/test", api.ApiSessionRequired(testEmail)).Methods("POST") + api.BaseRoutes.ApiRoot.Handle("/file/s3_test", api.ApiSessionRequired(testS3)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/database/recycle", api.ApiSessionRequired(databaseRecycle)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/caches/invalidate", api.ApiSessionRequired(invalidateCaches)).Methods("POST") @@ -384,3 +385,33 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(rows.ToJson())) } + +func testS3(c *Context, w http.ResponseWriter, r *http.Request) { + cfg := model.ConfigFromJson(r.Body) + if cfg == nil { + cfg = c.App.Config() + } + + if !c.App.SessionHasPermissionTo(c.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + err := utils.CheckMandatoryS3Fields(&cfg.FileSettings) + if err != nil { + c.Err = err + return + } + + license := c.App.License() + backend, appErr := utils.NewFileBackend(&cfg.FileSettings, license != nil && *license.Features.Compliance) + if appErr == nil { + appErr = backend.TestConnection() + } + if appErr != nil { + c.Err = appErr + return + } + + ReturnStatusOK(w) +} diff --git a/api4/system_test.go b/api4/system_test.go index 01b4934ae3..e39486b775 100644 --- a/api4/system_test.go +++ b/api4/system_test.go @@ -1,7 +1,9 @@ package api4 import ( + "fmt" "net/http" + "os" "strings" "testing" @@ -466,3 +468,65 @@ func TestGetAnalyticsOld(t *testing.T) { _, resp = Client.GetAnalyticsOld("", th.BasicTeam.Id) CheckUnauthorizedStatus(t, resp) } + +func TestS3TestConnection(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer th.TearDown() + Client := th.Client + + s3Host := os.Getenv("CI_HOST") + if s3Host == "" { + s3Host = "dockerhost" + } + + s3Port := os.Getenv("CI_MINIO_PORT") + if s3Port == "" { + s3Port = "9001" + } + + s3Endpoint := fmt.Sprintf("%s:%s", s3Host, s3Port) + config := model.Config{ + FileSettings: model.FileSettings{ + DriverName: model.NewString(model.IMAGE_DRIVER_S3), + AmazonS3AccessKeyId: model.MINIO_ACCESS_KEY, + AmazonS3SecretAccessKey: model.MINIO_SECRET_KEY, + AmazonS3Bucket: "", + AmazonS3Endpoint: "", + AmazonS3SSL: model.NewBool(false), + }, + } + + _, resp := Client.TestS3Connection(&config) + CheckForbiddenStatus(t, resp) + + _, resp = th.SystemAdminClient.TestS3Connection(&config) + CheckBadRequestStatus(t, resp) + if resp.Error.Message != "S3 Bucket is required" { + t.Fatal("should return error - missing s3 bucket") + } + + config.FileSettings.AmazonS3Bucket = model.MINIO_BUCKET + _, resp = th.SystemAdminClient.TestS3Connection(&config) + CheckBadRequestStatus(t, resp) + if resp.Error.Message != "S3 Endpoint is required" { + t.Fatal("should return error - missing s3 endpoint") + } + + config.FileSettings.AmazonS3Endpoint = s3Endpoint + _, resp = th.SystemAdminClient.TestS3Connection(&config) + CheckBadRequestStatus(t, resp) + if resp.Error.Message != "S3 Region is required" { + t.Fatal("should return error - missing s3 region") + } + + config.FileSettings.AmazonS3Region = "us-east-1" + _, resp = th.SystemAdminClient.TestS3Connection(&config) + CheckOKStatus(t, resp) + + config.FileSettings.AmazonS3Bucket = "Wrong_bucket" + _, resp = th.SystemAdminClient.TestS3Connection(&config) + CheckInternalErrorStatus(t, resp) + if resp.Error.Message != "Error checking if bucket exists." { + t.Fatal("should return error ") + } +} diff --git a/i18n/en.json b/i18n/en.json index 37639ba60d..1e4ac9012b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -107,6 +107,18 @@ "id": "api.admin.test_email.subject", "translation": "Mattermost - Testing Email Settings" }, + { + "id": "api.admin.test_s3.missing_s3_bucket", + "translation": "S3 Bucket is required" + }, + { + "id": "api.admin.test_s3.missing_s3_region", + "translation": "S3 Region is required" + }, + { + "id": "api.admin.test_s3.missing_s3_endpoint", + "translation": "S3 Endpoint is required" + }, { "id": "api.admin.upload_brand_image.array.app_error", "translation": "Empty array under 'image' in request" diff --git a/model/client4.go b/model/client4.go index 4772d38b3a..c1587f882e 100644 --- a/model/client4.go +++ b/model/client4.go @@ -198,6 +198,10 @@ func (c *Client4) GetTestEmailRoute() string { return fmt.Sprintf("/email/test") } +func (c *Client4) GetTestS3Route() string { + return fmt.Sprintf("/file/s3_test") +} + func (c *Client4) GetDatabaseRoute() string { return fmt.Sprintf("/database") } @@ -2092,6 +2096,16 @@ func (c *Client4) TestEmail() (bool, *Response) { } } +// TestS3Connection will attempt to connect to the AWS S3. +func (c *Client4) TestS3Connection(config *Config) (bool, *Response) { + if r, err := c.DoApiPost(c.GetTestS3Route(), config.ToJson()); err != nil { + return false, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) + } +} + // GetConfig will retrieve the server config with some sanitized items. func (c *Client4) GetConfig() (*Config, *Response) { if r, err := c.DoApiGet(c.GetConfigRoute(), ""); err != nil { diff --git a/utils/file_backend_s3.go b/utils/file_backend_s3.go index 8e72272a1b..b0601bc8ac 100644 --- a/utils/file_backend_s3.go +++ b/utils/file_backend_s3.go @@ -37,7 +37,10 @@ type S3FileBackend struct { // disables automatic region lookup. func (b *S3FileBackend) s3New() (*s3.Client, error) { var creds *credentials.Credentials - if b.signV2 { + + if b.accessKey == "" && b.secretKey == "" { + creds = credentials.NewIAM("") + } else if b.signV2 { creds = credentials.NewStatic(b.accessKey, b.secretKey, "", credentials.SignatureV2) } else { creds = credentials.NewStatic(b.accessKey, b.secretKey, "", credentials.SignatureV4) @@ -244,3 +247,19 @@ func s3CopyMetadata(encrypt bool) map[string]string { metaData["x-amz-server-side-encryption"] = "AES256" return metaData } + +func CheckMandatoryS3Fields(settings *model.FileSettings) *model.AppError { + if len(settings.AmazonS3Bucket) == 0 { + return model.NewAppError("S3File", "api.admin.test_s3.missing_s3_bucket", nil, "", http.StatusBadRequest) + } + + if len(settings.AmazonS3Endpoint) == 0 { + return model.NewAppError("S3File", "api.admin.test_s3.missing_s3_endpoint", nil, "", http.StatusBadRequest) + } + + if len(settings.AmazonS3Region) == 0 { + return model.NewAppError("S3File", "api.admin.test_s3.missing_s3_region", nil, "", http.StatusBadRequest) + } + + return nil +} diff --git a/utils/file_backend_s3_test.go b/utils/file_backend_s3_test.go new file mode 100644 index 0000000000..ff42a4d199 --- /dev/null +++ b/utils/file_backend_s3_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package utils + +import ( + "testing" + + "github.com/mattermost/mattermost-server/model" +) + +func TestCheckMandatoryS3Fields(t *testing.T) { + cfg := model.FileSettings{} + + err := CheckMandatoryS3Fields(&cfg) + if err == nil || err.Message != "api.admin.test_s3.missing_s3_bucket" { + t.Fatal("should've failed with missing s3 bucket") + } + + cfg.AmazonS3Bucket = "test-mm" + err = CheckMandatoryS3Fields(&cfg) + if err == nil || err.Message != "api.admin.test_s3.missing_s3_endpoint" { + t.Fatal("should've failed with missing s3 endpoint") + } + + cfg.AmazonS3Endpoint = "s3.newendpoint.com" + err = CheckMandatoryS3Fields(&cfg) + if err == nil || err.Message != "api.admin.test_s3.missing_s3_region" { + t.Fatal("should've failed with missing s3 region") + } + +} From 4b5541db46d75182a7a83b1361ed785b401b9118 Mon Sep 17 00:00:00 2001 From: Carlos Tadeu Panato Junior Date: Thu, 1 Mar 2018 12:58:35 +0100 Subject: [PATCH 40/42] [PLT-8024] Support LOGIN authentication method for SMTP (#8140) * [PLT-8024] Support LOGIN authentication method for SMTP * added initial unit tests --- utils/mail.go | 58 +++++++++++++++++++++++++++++++++++++---- utils/mail_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/utils/mail.go b/utils/mail.go index 9023f7090a..2bc0ce9e1a 100644 --- a/utils/mail.go +++ b/utils/mail.go @@ -5,6 +5,8 @@ package utils import ( "crypto/tls" + "errors" + "io" "mime" "net" "net/mail" @@ -15,8 +17,6 @@ import ( "net/http" - "io" - l4g "github.com/alecthomas/log4go" "github.com/mattermost/html2text" "github.com/mattermost/mattermost-server/model" @@ -26,6 +26,56 @@ func encodeRFC2047Word(s string) string { return mime.BEncoding.Encode("utf-8", s) } +type authChooser struct { + smtp.Auth + Config *model.Config +} + +func (a *authChooser) Start(server *smtp.ServerInfo) (string, []byte, error) { + a.Auth = LoginAuth(a.Config.EmailSettings.SMTPUsername, a.Config.EmailSettings.SMTPPassword, a.Config.EmailSettings.SMTPServer+":"+a.Config.EmailSettings.SMTPPort) + for _, method := range server.Auth { + if method == "PLAIN" { + a.Auth = smtp.PlainAuth("", a.Config.EmailSettings.SMTPUsername, a.Config.EmailSettings.SMTPPassword, a.Config.EmailSettings.SMTPServer+":"+a.Config.EmailSettings.SMTPPort) + break + } + } + return a.Auth.Start(server) +} + +type loginAuth struct { + username, password, host string +} + +func LoginAuth(username, password, host string) smtp.Auth { + return &loginAuth{username, password, host} +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + if !server.TLS { + return "", nil, errors.New("unencrypted connection") + } + + if server.Name != a.host { + return "", nil, errors.New("wrong host name") + } + + return "LOGIN", []byte{}, nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return []byte(a.username), nil + case "Password:": + return []byte(a.password), nil + default: + return nil, errors.New("Unkown fromServer") + } + } + return nil, nil +} + func connectToSMTPServer(config *model.Config) (net.Conn, *model.AppError) { var conn net.Conn var err error @@ -75,9 +125,7 @@ func newSMTPClient(conn net.Conn, config *model.Config) (*smtp.Client, *model.Ap } if *config.EmailSettings.EnableSMTPAuth { - auth := smtp.PlainAuth("", config.EmailSettings.SMTPUsername, config.EmailSettings.SMTPPassword, config.EmailSettings.SMTPServer+":"+config.EmailSettings.SMTPPort) - - if err = c.Auth(auth); err != nil { + if err = c.Auth(&authChooser{Config: config}); err != nil { return nil, model.NewAppError("SendMail", "utils.mail.new_client.auth.app_error", nil, err.Error(), http.StatusInternalServerError) } } diff --git a/utils/mail_test.go b/utils/mail_test.go index 068c90c60b..31a4f89967 100644 --- a/utils/mail_test.go +++ b/utils/mail_test.go @@ -7,6 +7,9 @@ import ( "strings" "testing" + "net/smtp" + + "github.com/mattermost/mattermost-server/model" "github.com/stretchr/testify/require" ) @@ -169,3 +172,64 @@ func TestSendMailUsingConfig(t *testing.T) { } } }*/ + +func TestAuthMethods(t *testing.T) { + config := model.Config{ + EmailSettings: model.EmailSettings{ + EnableSMTPAuth: model.NewBool(false), + SMTPUsername: "test", + SMTPPassword: "fakepass", + SMTPServer: "fakeserver", + SMTPPort: "25", + }, + } + + auth := &authChooser{Config: &config} + tests := []struct { + desc string + server *smtp.ServerInfo + err string + }{ + { + desc: "auth PLAIN success", + server: &smtp.ServerInfo{Name: "fakeserver:25", Auth: []string{"PLAIN"}, TLS: true}, + }, + { + desc: "auth PLAIN unencrypted connection fail", + server: &smtp.ServerInfo{Name: "fakeserver:25", Auth: []string{"PLAIN"}, TLS: false}, + err: "unencrypted connection", + }, + { + desc: "auth PLAIN wrong host name", + server: &smtp.ServerInfo{Name: "wrongServer:999", Auth: []string{"PLAIN"}, TLS: true}, + err: "wrong host name", + }, + { + desc: "auth LOGIN success", + server: &smtp.ServerInfo{Name: "fakeserver:25", Auth: []string{"LOGIN"}, TLS: true}, + }, + { + desc: "auth LOGIN unencrypted connection fail", + server: &smtp.ServerInfo{Name: "wrongServer:999", Auth: []string{"LOGIN"}, TLS: true}, + err: "wrong host name", + }, + { + desc: "auth LOGIN wrong host name", + server: &smtp.ServerInfo{Name: "fakeserver:25", Auth: []string{"LOGIN"}, TLS: false}, + err: "unencrypted connection", + }, + } + + for i, test := range tests { + t.Run(test.desc, func(t *testing.T) { + _, _, err := auth.Start(test.server) + got := "" + if err != nil { + got = err.Error() + } + if got != test.err { + t.Errorf("%d. got error = %q; want %q", i, got, test.err) + } + }) + } +} From 51c7198d53a2fbc4c7d47b3eb308781b69bf4b51 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 1 Mar 2018 09:16:23 -0600 Subject: [PATCH 41/42] fix linux cap header structure (#8389) --- plugin/rpcplugin/sandbox/sandbox_linux.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/rpcplugin/sandbox/sandbox_linux.go b/plugin/rpcplugin/sandbox/sandbox_linux.go index dad485f682..c83572c820 100644 --- a/plugin/rpcplugin/sandbox/sandbox_linux.go +++ b/plugin/rpcplugin/sandbox/sandbox_linux.go @@ -267,7 +267,7 @@ func pivotRoot(newRoot string) error { func dropInheritableCapabilities() error { type capHeader struct { version uint32 - pid int + pid int32 } type capData struct { From 2b3b6051d265edf131d006b2eb14f55284faf1e5 Mon Sep 17 00:00:00 2001 From: Christian Hoff Date: Thu, 1 Mar 2018 20:11:44 +0100 Subject: [PATCH 42/42] PLT-7567: Integration of Team Icons (#8284) * PLT-7567: Integration of Team Icons * PLT-7567: Read replica workaround, upgrade logic moved, more concrete i18n key * PLT-7567: Read replica workaround, corrections * PLT-7567: upgrade correction --- api4/team.go | 83 +++++++++++++++++++++++++++ api4/team_test.go | 81 +++++++++++++++++++++++++++ app/team.go | 90 ++++++++++++++++++++++++++++++ i18n/en.json | 44 +++++++++++++++ model/client4.go | 53 ++++++++++++++++++ model/team.go | 27 ++++----- store/sqlstore/team_store.go | 11 ++++ store/sqlstore/upgrade.go | 2 + store/store.go | 1 + store/storetest/mocks/TeamStore.go | 16 ++++++ store/storetest/team_store.go | 26 +++++++++ 11 files changed, 421 insertions(+), 13 deletions(-) diff --git a/api4/team.go b/api4/team.go index d770aee220..8e4c5c3124 100644 --- a/api4/team.go +++ b/api4/team.go @@ -6,6 +6,7 @@ package api4 import ( "bytes" "encoding/base64" + "fmt" "net/http" "strconv" @@ -28,6 +29,10 @@ func (api *API) InitTeam() { api.BaseRoutes.Team.Handle("", api.ApiSessionRequired(deleteTeam)).Methods("DELETE") api.BaseRoutes.Team.Handle("/patch", api.ApiSessionRequired(patchTeam)).Methods("PUT") api.BaseRoutes.Team.Handle("/stats", api.ApiSessionRequired(getTeamStats)).Methods("GET") + + api.BaseRoutes.Team.Handle("/image", api.ApiSessionRequiredTrustRequester(getTeamIcon)).Methods("GET") + api.BaseRoutes.Team.Handle("/image", api.ApiSessionRequired(setTeamIcon)).Methods("POST") + api.BaseRoutes.TeamMembers.Handle("", api.ApiSessionRequired(getTeamMembers)).Methods("GET") api.BaseRoutes.TeamMembers.Handle("/ids", api.ApiSessionRequired(getTeamMembersByIds)).Methods("POST") api.BaseRoutes.TeamMembersForUser.Handle("", api.ApiSessionRequired(getTeamMembersForUser)).Methods("GET") @@ -729,3 +734,81 @@ func getInviteInfo(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(result))) } } + +func getTeamIcon(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTeamId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToTeam(c.Session, c.Params.TeamId, model.PERMISSION_VIEW_TEAM) { + c.SetPermissionError(model.PERMISSION_VIEW_TEAM) + return + } + + if team, err := c.App.GetTeam(c.Params.TeamId); err != nil { + c.Err = err + return + } else { + etag := strconv.FormatInt(team.LastTeamIconUpdate, 10) + + if c.HandleEtag(etag, "Get Team Icon", w, r) { + return + } + + if img, err := c.App.GetTeamIcon(team); err != nil { + c.Err = err + return + } else { + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%v, public", 24*60*60)) // 24 hrs + w.Header().Set(model.HEADER_ETAG_SERVER, etag) + w.Write(img) + } + } +} + +func setTeamIcon(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTeamId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToTeam(c.Session, c.Params.TeamId, model.PERMISSION_MANAGE_TEAM) { + c.SetPermissionError(model.PERMISSION_MANAGE_TEAM) + return + } + + if r.ContentLength > *c.App.Config().FileSettings.MaxFileSize { + c.Err = model.NewAppError("setTeamIcon", "api.team.set_team_icon.too_large.app_error", nil, "", http.StatusBadRequest) + return + } + + if err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize); err != nil { + c.Err = model.NewAppError("setTeamIcon", "api.team.set_team_icon.parse.app_error", nil, err.Error(), http.StatusBadRequest) + return + } + + m := r.MultipartForm + + imageArray, ok := m.File["image"] + if !ok { + c.Err = model.NewAppError("setTeamIcon", "api.team.set_team_icon.no_file.app_error", nil, "", http.StatusBadRequest) + return + } + + if len(imageArray) <= 0 { + c.Err = model.NewAppError("setTeamIcon", "api.team.set_team_icon.array.app_error", nil, "", http.StatusBadRequest) + return + } + + imageData := imageArray[0] + + if err := c.App.SetTeamIcon(c.Params.TeamId, imageData); err != nil { + c.Err = err + return + } + + c.LogAudit("") + ReturnStatusOK(w) +} diff --git a/api4/team_test.go b/api4/team_test.go index faa90e5112..04a0e9ae49 100644 --- a/api4/team_test.go +++ b/api4/team_test.go @@ -15,6 +15,8 @@ import ( "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCreateTeam(t *testing.T) { @@ -1915,3 +1917,82 @@ func TestGetTeamInviteInfo(t *testing.T) { _, resp = Client.GetTeamInviteInfo("junk") CheckNotFoundStatus(t, resp) } + +func TestSetTeamIcon(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer th.TearDown() + Client := th.Client + team := th.BasicTeam + + data, err := readTestFile("test.png") + if err != nil { + t.Fatal(err) + } + + th.LoginTeamAdmin() + + ok, resp := Client.SetTeamIcon(team.Id, data) + if !ok { + t.Fatal(resp.Error) + } + CheckNoError(t, resp) + + ok, resp = Client.SetTeamIcon(model.NewId(), data) + if ok { + t.Fatal("Should return false, set team icon not allowed") + } + CheckForbiddenStatus(t, resp) + + th.LoginBasic() + + _, resp = Client.SetTeamIcon(team.Id, data) + if resp.StatusCode == http.StatusForbidden { + CheckForbiddenStatus(t, resp) + } else if resp.StatusCode == http.StatusUnauthorized { + CheckUnauthorizedStatus(t, resp) + } else { + t.Fatal("Should have failed either forbidden or unauthorized") + } + + Client.Logout() + + _, resp = Client.SetTeamIcon(team.Id, data) + if resp.StatusCode == http.StatusForbidden { + CheckForbiddenStatus(t, resp) + } else if resp.StatusCode == http.StatusUnauthorized { + CheckUnauthorizedStatus(t, resp) + } else { + t.Fatal("Should have failed either forbidden or unauthorized") + } + + teamBefore, err := th.App.GetTeam(team.Id) + require.Nil(t, err) + + _, resp = th.SystemAdminClient.SetTeamIcon(team.Id, data) + CheckNoError(t, resp) + + teamAfter, err := th.App.GetTeam(team.Id) + require.Nil(t, err) + assert.True(t, teamBefore.LastTeamIconUpdate < teamAfter.LastTeamIconUpdate, "LastTeamIconUpdate should have been updated for team") + + info := &model.FileInfo{Path: "teams/" + team.Id + "/teamIcon.png"} + if err := th.cleanupTestFile(info); err != nil { + t.Fatal(err) + } +} + +func TestGetTeamIcon(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer th.TearDown() + Client := th.Client + team := th.BasicTeam + + // should always fail because no initial image and no auto creation + _, resp := Client.GetTeamIcon(team.Id, "") + CheckNotFoundStatus(t, resp) + + Client.Logout() + + _, resp = Client.GetTeamIcon(team.Id, "") + CheckUnauthorizedStatus(t, resp) +} diff --git a/app/team.go b/app/team.go index d8750bfbb9..65b1934f9a 100644 --- a/app/team.go +++ b/app/team.go @@ -4,13 +4,18 @@ package app import ( + "bytes" "fmt" + "image" + "image/png" + "mime/multipart" "net/http" "net/url" "strconv" "strings" l4g "github.com/alecthomas/log4go" + "github.com/disintegration/imaging" "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/utils" @@ -919,3 +924,88 @@ func (a *App) SanitizeTeams(session model.Session, teams []*model.Team) []*model return teams } + +func (a *App) GetTeamIcon(team *model.Team) ([]byte, *model.AppError) { + if len(*a.Config().FileSettings.DriverName) == 0 { + return nil, model.NewAppError("GetTeamIcon", "api.team.get_team_icon.filesettings_no_driver.app_error", nil, "", http.StatusNotImplemented) + } else { + path := "teams/" + team.Id + "/teamIcon.png" + if data, err := a.ReadFile(path); err != nil { + return nil, model.NewAppError("GetTeamIcon", "api.team.get_team_icon.read_file.app_error", nil, err.Error(), http.StatusNotFound) + } else { + return data, nil + } + } +} + +func (a *App) SetTeamIcon(teamId string, imageData *multipart.FileHeader) *model.AppError { + file, err := imageData.Open() + if err != nil { + return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.open.app_error", nil, err.Error(), http.StatusBadRequest) + } + defer file.Close() + return a.SetTeamIconFromFile(teamId, file) +} + +func (a *App) SetTeamIconFromFile(teamId string, file multipart.File) *model.AppError { + + team, getTeamErr := a.GetTeam(teamId) + + if getTeamErr != nil { + return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.get_team.app_error", nil, getTeamErr.Error(), http.StatusBadRequest) + } + + if len(*a.Config().FileSettings.DriverName) == 0 { + return model.NewAppError("setTeamIcon", "api.team.set_team_icon.storage.app_error", nil, "", http.StatusNotImplemented) + } + + // Decode image config first to check dimensions before loading the whole thing into memory later on + config, _, err := image.DecodeConfig(file) + if err != nil { + return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.decode_config.app_error", nil, err.Error(), http.StatusBadRequest) + } else if config.Width*config.Height > model.MaxImageSize { + return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.too_large.app_error", nil, err.Error(), http.StatusBadRequest) + } + + file.Seek(0, 0) + + // Decode image into Image object + img, _, err := image.Decode(file) + if err != nil { + return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.decode.app_error", nil, err.Error(), http.StatusBadRequest) + } + + file.Seek(0, 0) + + orientation, _ := getImageOrientation(file) + img = makeImageUpright(img, orientation) + + // Scale team icon + teamIconWidthAndHeight := 128 + img = imaging.Fill(img, teamIconWidthAndHeight, teamIconWidthAndHeight, imaging.Center, imaging.Lanczos) + + buf := new(bytes.Buffer) + err = png.Encode(buf, img) + if err != nil { + return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.encode.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + path := "teams/" + teamId + "/teamIcon.png" + + if err := a.WriteFile(buf.Bytes(), path); err != nil { + return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.write_file.app_error", nil, "", http.StatusInternalServerError) + } + + curTime := model.GetMillis() + + if result := <-a.Srv.Store.Team().UpdateLastTeamIconUpdate(teamId, curTime); result.Err != nil { + return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.update.app_error", nil, result.Err.Error(), http.StatusBadRequest) + } + + // manually set time to avoid possible cluster inconsistencies + team.LastTeamIconUpdate = curTime + + a.sendTeamEvent(team, model.WEBSOCKET_EVENT_UPDATE_TEAM) + + return nil +} diff --git a/i18n/en.json b/i18n/en.json index 1e4ac9012b..2efb13375e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2198,6 +2198,50 @@ "id": "api.system.go_routines", "translation": "The number of running goroutines is over the health threshold %v of %v" }, + { + "id": "api.team.set_team_icon.get_team.app_error", + "translation": "An error occurred getting the team" + }, + { + "id": "api.team.set_team_icon.storage.app_error", + "translation": "Unable to upload team icon. Image storage is not configured." + }, + { + "id": "api.team.set_team_icon.too_large.app_error", + "translation": "Unable to upload team icon. File is too large." + }, + { + "id": "api.team.set_team_icon.parse.app_error", + "translation": "Could not parse multipart form" + }, + { + "id": "api.team.set_team_icon.no_file.app_error", + "translation": "No file under 'image' in request" + }, + { + "id": "api.team.set_team_icon.array.app_error", + "translation": "Empty array under 'image' in request" + }, + { + "id": "api.team.set_team_icon.open.app_error", + "translation": "Could not open image file" + }, + { + "id": "api.team.set_team_icon.decode_config.app_error", + "translation": "Could not decode team icon metadata" + }, + { + "id": "api.team.set_team_icon.decode.app_error", + "translation": "Could not decode team icon" + }, + { + "id": "api.team.set_team_icon.encode.app_error", + "translation": "Could not encode team icon" + }, + { + "id": "api.team.set_team_icon.write_file.app_error", + "translation": "Could not save team icon" + }, { "id": "api.team.add_user_to_team.added", "translation": "%v added to the team by %v." diff --git a/model/client4.go b/model/client4.go index c1587f882e..1d71d7b3ed 100644 --- a/model/client4.go +++ b/model/client4.go @@ -3318,3 +3318,56 @@ func (c *Client4) DeactivatePlugin(id string) (bool, *Response) { return CheckStatusOK(r), BuildResponse(r) } } + +// SetTeamIcon sets team icon of the team +func (c *Client4) SetTeamIcon(teamId string, data []byte) (bool, *Response) { + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + if part, err := writer.CreateFormFile("image", "teamIcon.png"); err != nil { + return false, &Response{Error: NewAppError("SetTeamIcon", "model.client.set_team_icon.no_file.app_error", nil, err.Error(), http.StatusBadRequest)} + } else if _, err = io.Copy(part, bytes.NewBuffer(data)); err != nil { + return false, &Response{Error: NewAppError("SetTeamIcon", "model.client.set_team_icon.no_file.app_error", nil, err.Error(), http.StatusBadRequest)} + } + + if err := writer.Close(); err != nil { + return false, &Response{Error: NewAppError("SetTeamIcon", "model.client.set_team_icon.writer.app_error", nil, err.Error(), http.StatusBadRequest)} + } + + rq, _ := http.NewRequest("POST", c.ApiUrl+c.GetTeamRoute(teamId)+"/image", bytes.NewReader(body.Bytes())) + rq.Header.Set("Content-Type", writer.FormDataContentType()) + rq.Close = true + + if len(c.AuthToken) > 0 { + rq.Header.Set(HEADER_AUTH, c.AuthType+" "+c.AuthToken) + } + + if rp, err := c.HttpClient.Do(rq); err != nil || rp == nil { + // set to http.StatusForbidden(403) + return false, &Response{StatusCode: http.StatusForbidden, Error: NewAppError(c.GetTeamRoute(teamId)+"/image", "model.client.connecting.app_error", nil, err.Error(), 403)} + } else { + defer closeBody(rp) + + if rp.StatusCode >= 300 { + return false, BuildErrorResponse(rp, AppErrorFromJson(rp.Body)) + } else { + return CheckStatusOK(rp), BuildResponse(rp) + } + } +} + +// GetTeamIcon gets the team icon of the team +func (c *Client4) GetTeamIcon(teamId, etag string) ([]byte, *Response) { + if r, err := c.DoApiGet(c.GetTeamRoute(teamId)+"/image", etag); err != nil { + return nil, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + + if data, err := ioutil.ReadAll(r.Body); err != nil { + return nil, BuildErrorResponse(r, NewAppError("GetTeamIcon", "model.client.get_team_icon.app_error", nil, err.Error(), r.StatusCode)) + } else { + return data, BuildResponse(r) + } + } +} diff --git a/model/team.go b/model/team.go index 5b6eb1fa09..15a7082203 100644 --- a/model/team.go +++ b/model/team.go @@ -26,19 +26,20 @@ const ( ) type Team struct { - Id string `json:"id"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` - DeleteAt int64 `json:"delete_at"` - DisplayName string `json:"display_name"` - Name string `json:"name"` - Description string `json:"description"` - Email string `json:"email"` - Type string `json:"type"` - CompanyName string `json:"company_name"` - AllowedDomains string `json:"allowed_domains"` - InviteId string `json:"invite_id"` - AllowOpenInvite bool `json:"allow_open_invite"` + Id string `json:"id"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + DisplayName string `json:"display_name"` + Name string `json:"name"` + Description string `json:"description"` + Email string `json:"email"` + Type string `json:"type"` + CompanyName string `json:"company_name"` + AllowedDomains string `json:"allowed_domains"` + InviteId string `json:"invite_id"` + AllowOpenInvite bool `json:"allow_open_invite"` + LastTeamIconUpdate int64 `json:"last_team_icon_update,omitempty"` } type TeamPatch struct { diff --git a/store/sqlstore/team_store.go b/store/sqlstore/team_store.go index cddfb7c1ab..6528b8e4cc 100644 --- a/store/sqlstore/team_store.go +++ b/store/sqlstore/team_store.go @@ -99,6 +99,7 @@ func (s SqlTeamStore) Update(team *model.Team) store.StoreChannel { team.CreateAt = oldTeam.CreateAt team.UpdateAt = model.GetMillis() team.Name = oldTeam.Name + team.LastTeamIconUpdate = oldTeam.LastTeamIconUpdate if count, err := s.GetMaster().Update(team); err != nil { result.Err = model.NewAppError("SqlTeamStore.Update", "store.sql_team.update.updating.app_error", nil, "id="+team.Id+", "+err.Error(), http.StatusInternalServerError) @@ -559,3 +560,13 @@ func (s SqlTeamStore) RemoveAllMembersByUser(userId string) store.StoreChannel { } }) } + +func (us SqlTeamStore) UpdateLastTeamIconUpdate(teamId string, curTime int64) store.StoreChannel { + return store.Do(func(result *store.StoreResult) { + if _, err := us.GetMaster().Exec("UPDATE Teams SET LastTeamIconUpdate = :Time, UpdateAt = :Time WHERE Id = :teamId", map[string]interface{}{"Time": curTime, "teamId": teamId}); err != nil { + result.Err = model.NewAppError("SqlTeamStore.UpdateLastTeamIconUpdate", "store.sql_team.update_last_team_icon_update.app_error", nil, "team_id="+teamId, http.StatusInternalServerError) + } else { + result.Data = teamId + } + }) +} diff --git a/store/sqlstore/upgrade.go b/store/sqlstore/upgrade.go index 77289183c1..9cbef233e7 100644 --- a/store/sqlstore/upgrade.go +++ b/store/sqlstore/upgrade.go @@ -365,8 +365,10 @@ func UpgradeDatabaseToVersion471(sqlStore SqlStore) { } func UpgradeDatabaseToVersion48(sqlStore SqlStore) { + //TODO: Uncomment the following condition when version 4.8.0 is released //if shouldPerformUpgrade(sqlStore, VERSION_4_7_0, VERSION_4_8_0) { + sqlStore.CreateColumnIfNotExists("Teams", "LastTeamIconUpdate", "bigint", "bigint", "0") // saveSchemaVersion(sqlStore, VERSION_4_8_0) //} } diff --git a/store/store.go b/store/store.go index 85f215ab9c..9435a6f615 100644 --- a/store/store.go +++ b/store/store.go @@ -103,6 +103,7 @@ type TeamStore interface { RemoveMember(teamId string, userId string) StoreChannel RemoveAllMembersByTeam(teamId string) StoreChannel RemoveAllMembersByUser(userId string) StoreChannel + UpdateLastTeamIconUpdate(teamId string, curTime int64) StoreChannel } type ChannelStore interface { diff --git a/store/storetest/mocks/TeamStore.go b/store/storetest/mocks/TeamStore.go index bdad7f81ba..8a7f030dce 100644 --- a/store/storetest/mocks/TeamStore.go +++ b/store/storetest/mocks/TeamStore.go @@ -476,3 +476,19 @@ func (_m *TeamStore) UpdateMember(member *model.TeamMember) store.StoreChannel { return r0 } + +// UpdateLastTeamIconUpdate provides a mock function with given fields: teamId +func (_m *TeamStore) UpdateLastTeamIconUpdate(teamId string, curTime int64) store.StoreChannel { + ret := _m.Called(teamId) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, int64) store.StoreChannel); ok { + r0 = rf(teamId, curTime) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} diff --git a/store/storetest/team_store.go b/store/storetest/team_store.go index a32de9dba0..cab06f87ff 100644 --- a/store/storetest/team_store.go +++ b/store/storetest/team_store.go @@ -33,6 +33,7 @@ func TestTeamStore(t *testing.T, ss store.Store) { t.Run("MemberCount", func(t *testing.T) { testTeamStoreMemberCount(t, ss) }) t.Run("GetChannelUnreadsForAllTeams", func(t *testing.T) { testGetChannelUnreadsForAllTeams(t, ss) }) t.Run("GetChannelUnreadsForTeam", func(t *testing.T) { testGetChannelUnreadsForTeam(t, ss) }) + t.Run("UpdateLastTeamIconUpdate", func(t *testing.T) { testUpdateLastTeamIconUpdate(t, ss) }) } func testTeamStoreSave(t *testing.T, ss store.Store) { @@ -1003,3 +1004,28 @@ func testGetChannelUnreadsForTeam(t *testing.T, ss store.Store) { } } } + +func testUpdateLastTeamIconUpdate(t *testing.T, ss store.Store) { + + // team icon initially updated a second ago + lastTeamIconUpdateInitial := model.GetMillis() - 1000 + + o1 := &model.Team{} + o1.DisplayName = "Display Name" + o1.Name = "z-z-z" + model.NewId() + "b" + o1.Email = model.NewId() + "@nowhere.com" + o1.Type = model.TEAM_OPEN + o1.LastTeamIconUpdate = lastTeamIconUpdateInitial + o1 = (<-ss.Team().Save(o1)).Data.(*model.Team) + + curTime := model.GetMillis() + + if err := (<-ss.Team().UpdateLastTeamIconUpdate(o1.Id, curTime)).Err; err != nil { + t.Fatal(err) + } + + ro1 := (<-ss.Team().Get(o1.Id)).Data.(*model.Team) + if ro1.LastTeamIconUpdate <= lastTeamIconUpdateInitial { + t.Fatal("LastTeamIconUpdate not updated") + } +}