sync profile images (#17375)
support for synchronizing user's profile image for shared channels.
Этот коммит содержится в:
2
Makefile
2
Makefile
@@ -374,7 +374,7 @@ test-server-race: test-server-pre
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
test-server: test-server-pre
|
test-server: test-server-pre
|
||||||
./scripts/test.sh "$(GO)" "$(GOFLAGS)" "$(ALL_PACKAGES)" "$(TESTS)" "$(TESTFLAGS)" "$(GOBIN)" "20m" "count"
|
./scripts/test.sh "$(GO)" "$(GOFLAGS)" "$(ALL_PACKAGES)" "$(TESTS)" "$(TESTFLAGS)" "$(GOBIN)" "45m" "count"
|
||||||
ifneq ($(IS_CI),true)
|
ifneq ($(IS_CI),true)
|
||||||
ifneq ($(MM_NO_DOCKER),true)
|
ifneq ($(MM_NO_DOCKER),true)
|
||||||
ifneq ($(TEMP_DOCKER_SERVICES),)
|
ifneq ($(TEMP_DOCKER_SERVICES),)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package api4
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ func (api *API) InitRemoteCluster() {
|
|||||||
api.BaseRoutes.RemoteCluster.Handle("/msg", api.RemoteClusterTokenRequired(remoteClusterAcceptMessage)).Methods("POST")
|
api.BaseRoutes.RemoteCluster.Handle("/msg", api.RemoteClusterTokenRequired(remoteClusterAcceptMessage)).Methods("POST")
|
||||||
api.BaseRoutes.RemoteCluster.Handle("/confirm_invite", api.RemoteClusterTokenRequired(remoteClusterConfirmInvite)).Methods("POST")
|
api.BaseRoutes.RemoteCluster.Handle("/confirm_invite", api.RemoteClusterTokenRequired(remoteClusterConfirmInvite)).Methods("POST")
|
||||||
api.BaseRoutes.RemoteCluster.Handle("/upload/{upload_id:[A-Za-z0-9]+}", api.RemoteClusterTokenRequired(uploadRemoteData)).Methods("POST")
|
api.BaseRoutes.RemoteCluster.Handle("/upload/{upload_id:[A-Za-z0-9]+}", api.RemoteClusterTokenRequired(uploadRemoteData)).Methods("POST")
|
||||||
|
api.BaseRoutes.RemoteCluster.Handle("/{user_id:[A-Za-z0-9]+}/image", api.RemoteClusterTokenRequired(remoteSetProfileImage)).Methods("POST")
|
||||||
}
|
}
|
||||||
|
|
||||||
func remoteClusterPing(c *Context, w http.ResponseWriter, r *http.Request) {
|
func remoteClusterPing(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -212,3 +215,63 @@ func uploadRemoteData(c *Context, w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.Write([]byte(info.ToJson()))
|
w.Write([]byte(info.ToJson()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remoteSetProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer io.Copy(ioutil.Discard, r.Body)
|
||||||
|
|
||||||
|
c.RequireUserId()
|
||||||
|
if c.Err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *c.App.Config().FileSettings.DriverName == "" {
|
||||||
|
c.Err = model.NewAppError("remoteUploadProfileImage", "api.user.upload_profile_user.storage.app_error", nil, "", http.StatusNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.ContentLength > *c.App.Config().FileSettings.MaxFileSize {
|
||||||
|
c.Err = model.NewAppError("remoteUploadProfileImage", "api.user.upload_profile_user.too_large.app_error", nil, "", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize); err != nil {
|
||||||
|
c.Err = model.NewAppError("remoteUploadProfileImage", "api.user.upload_profile_user.parse.app_error", nil, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m := r.MultipartForm
|
||||||
|
imageArray, ok := m.File["image"]
|
||||||
|
if !ok {
|
||||||
|
c.Err = model.NewAppError("remoteUploadProfileImage", "api.user.upload_profile_user.no_file.app_error", nil, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(imageArray) == 0 {
|
||||||
|
c.Err = model.NewAppError("remoteUploadProfileImage", "api.user.upload_profile_user.array.app_error", nil, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auditRec := c.MakeAuditRecord("remoteUploadProfileImage", audit.Fail)
|
||||||
|
defer c.LogAuditRec(auditRec)
|
||||||
|
if imageArray[0] != nil {
|
||||||
|
auditRec.AddMeta("filename", imageArray[0].Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := c.App.GetUser(c.Params.UserId)
|
||||||
|
if err != nil || !user.IsRemote() {
|
||||||
|
c.SetInvalidUrlParam("user_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditRec.AddMeta("user", user)
|
||||||
|
|
||||||
|
imageData := imageArray[0]
|
||||||
|
if err := c.App.SetProfileImage(c.Params.UserId, imageData); err != nil {
|
||||||
|
c.Err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auditRec.Success()
|
||||||
|
c.LogAudit("")
|
||||||
|
|
||||||
|
ReturnStatusOK(w)
|
||||||
|
}
|
||||||
|
|||||||
@@ -752,6 +752,14 @@ func (u *User) IsRemote() bool {
|
|||||||
return u.RemoteId != nil && *u.RemoteId != ""
|
return u.RemoteId != nil && *u.RemoteId != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRemoteID returns the remote id for this user or "" if not a remote user.
|
||||||
|
func (u *User) GetRemoteID() string {
|
||||||
|
if u.RemoteId != nil {
|
||||||
|
return *u.RemoteId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// GetProp fetches a prop value by name.
|
// GetProp fetches a prop value by name.
|
||||||
func (u *User) GetProp(name string) (string, bool) {
|
func (u *User) GetProp(name string) (string, bool) {
|
||||||
val, ok := u.Props[name]
|
val, ok := u.Props[name]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package remotecluster
|
package remotecluster
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
type mockServer struct {
|
type mockServer struct {
|
||||||
remotes []*model.RemoteCluster
|
remotes []*model.RemoteCluster
|
||||||
logger *mockLogger
|
logger *mockLogger
|
||||||
|
user *model.User
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMockServer(t *testing.T, remotes []*model.RemoteCluster) *mockServer {
|
func newMockServer(t *testing.T, remotes []*model.RemoteCluster) *mockServer {
|
||||||
@@ -31,6 +33,10 @@ func newMockServer(t *testing.T, remotes []*model.RemoteCluster) *mockServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ms *mockServer) SetUser(user *model.User) {
|
||||||
|
ms.user = user
|
||||||
|
}
|
||||||
|
|
||||||
func (ms *mockServer) Config() *model.Config { return nil }
|
func (ms *mockServer) Config() *model.Config { return nil }
|
||||||
func (ms *mockServer) GetMetrics() einterfaces.MetricsInterface { return nil }
|
func (ms *mockServer) GetMetrics() einterfaces.MetricsInterface { return nil }
|
||||||
func (ms *mockServer) IsLeader() bool { return true }
|
func (ms *mockServer) IsLeader() bool { return true }
|
||||||
@@ -40,16 +46,21 @@ func (ms *mockServer) GetLogger() mlog.LoggerIFace {
|
|||||||
return ms.logger
|
return ms.logger
|
||||||
}
|
}
|
||||||
func (ms *mockServer) GetStore() store.Store {
|
func (ms *mockServer) GetStore() store.Store {
|
||||||
anyFilter := mock.MatchedBy(func(filter model.RemoteClusterQueryFilter) bool {
|
anyQueryFilter := mock.MatchedBy(func(filter model.RemoteClusterQueryFilter) bool {
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
anyUserId := mock.AnythingOfType("string")
|
||||||
|
|
||||||
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
|
remoteClusterStoreMock := &mocks.RemoteClusterStore{}
|
||||||
remoteClusterStoreMock.On("GetByTopic", "share").Return(ms.remotes, nil)
|
remoteClusterStoreMock.On("GetByTopic", "share").Return(ms.remotes, nil)
|
||||||
remoteClusterStoreMock.On("GetAll", anyFilter).Return(ms.remotes, nil)
|
remoteClusterStoreMock.On("GetAll", anyQueryFilter).Return(ms.remotes, nil)
|
||||||
|
|
||||||
|
userStoreMock := &mocks.UserStore{}
|
||||||
|
userStoreMock.On("Get", context.Background(), anyUserId).Return(ms.user, nil)
|
||||||
|
|
||||||
storeMock := &mocks.Store{}
|
storeMock := &mocks.Store{}
|
||||||
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
|
storeMock.On("RemoteCluster").Return(remoteClusterStoreMock)
|
||||||
|
storeMock.On("User").Return(userStoreMock)
|
||||||
return storeMock
|
return storeMock
|
||||||
}
|
}
|
||||||
func (ms *mockServer) Shutdown() { ms.logger.Shutdown() }
|
func (ms *mockServer) Shutdown() { ms.logger.Shutdown() }
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ func (rcs *Service) sendLoop(idx int, done chan struct{}) {
|
|||||||
rcs.sendMsg(task)
|
rcs.sendMsg(task)
|
||||||
case sendFileTask:
|
case sendFileTask:
|
||||||
rcs.sendFile(task)
|
rcs.sendFile(task)
|
||||||
|
case sendProfileImageTask:
|
||||||
|
rcs.sendProfileImage(task)
|
||||||
}
|
}
|
||||||
case <-done:
|
case <-done:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ type ReaderProvider interface {
|
|||||||
// SendFile asynchronously sends a file to a remote cluster.
|
// SendFile asynchronously sends a file to a remote cluster.
|
||||||
//
|
//
|
||||||
// `ctx` determines behaviour when the outbound queue is full. A timeout or deadline context will return a
|
// `ctx` determines behaviour when the outbound queue is full. A timeout or deadline context will return a
|
||||||
// BufferFullError if the file cannot be enqueued before the timeout. A background context will block indefinitely.
|
// BufferFullError if the task cannot be enqueued before the timeout. A background context will block indefinitely.
|
||||||
//
|
//
|
||||||
// Nil or error return indicates success or failure of file enqueue only.
|
// Nil or error return indicates success or failure of task enqueue only.
|
||||||
//
|
//
|
||||||
// An optional callback can be provided that receives the response from the remote cluster. The `err` provided to the
|
// An optional callback can be provided that receives the response from the remote cluster. The `err` provided to the
|
||||||
// callback is regarding file delivery only. The `resp` contains the decoded bytes returned from the remote.
|
// callback is regarding file delivery only. The `resp` contains the decoded bytes returned from the remote.
|
||||||
@@ -55,17 +55,6 @@ func (rcs *Service) SendFile(ctx context.Context, us *model.UploadSession, fi *m
|
|||||||
|
|
||||||
// sendFile is called when a sendFileTask is popped from the send channel.
|
// sendFile is called when a sendFileTask is popped from the send channel.
|
||||||
func (rcs *Service) sendFile(task sendFileTask) {
|
func (rcs *Service) sendFile(task sendFileTask) {
|
||||||
// Ensure a panic from the callback does not exit the goroutine.
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Remote Cluster sendFile panic",
|
|
||||||
mlog.String("remote", task.rc.DisplayName),
|
|
||||||
mlog.String("uploadId", task.us.Id),
|
|
||||||
mlog.Any("panic", r),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
fi, err := rcs.sendFileToRemote(SendTimeout, task)
|
fi, err := rcs.sendFileToRemote(SendTimeout, task)
|
||||||
var response Response
|
var response Response
|
||||||
|
|
||||||
|
|||||||
@@ -82,13 +82,6 @@ func (rcs *Service) sendMsg(task sendMsgTask) {
|
|||||||
|
|
||||||
// Ensure a panic from the callback does not exit the pool goroutine.
|
// Ensure a panic from the callback does not exit the pool goroutine.
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
|
||||||
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Remote Cluster sendMsg panic",
|
|
||||||
mlog.String("remote", task.rc.DisplayName),
|
|
||||||
mlog.String("msgId", task.msg.Id), mlog.Any("panic", r),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if errResp != nil {
|
if errResp != nil {
|
||||||
response.Err = errResp.Error()
|
response.Err = errResp.Error()
|
||||||
}
|
}
|
||||||
|
|||||||
146
services/remotecluster/sendprofileImage.go
Обычный файл
146
services/remotecluster/sendprofileImage.go
Обычный файл
@@ -0,0 +1,146 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
package remotecluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SendProfileImageResultFunc func(userId string, rc *model.RemoteCluster, resp *Response, err error)
|
||||||
|
|
||||||
|
type sendProfileImageTask struct {
|
||||||
|
rc *model.RemoteCluster
|
||||||
|
userID string
|
||||||
|
provider ProfileImageProvider
|
||||||
|
f SendProfileImageResultFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileImageProvider interface {
|
||||||
|
GetProfileImage(user *model.User) ([]byte, bool, *model.AppError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendProfileImage asynchronously sends a user's profile image to a remote cluster.
|
||||||
|
//
|
||||||
|
// `ctx` determines behaviour when the outbound queue is full. A timeout or deadline context will return a
|
||||||
|
// BufferFullError if the task cannot be enqueued before the timeout. A background context will block indefinitely.
|
||||||
|
//
|
||||||
|
// Nil or error return indicates success or failure of task enqueue only.
|
||||||
|
//
|
||||||
|
// An optional callback can be provided that receives the response from the remote cluster. The `err` provided to the
|
||||||
|
// callback is regarding image delivery only. The `resp` contains the decoded bytes returned from the remote.
|
||||||
|
// If a callback is provided it should return quickly.
|
||||||
|
func (rcs *Service) SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider ProfileImageProvider, f SendProfileImageResultFunc) error {
|
||||||
|
task := sendProfileImageTask{
|
||||||
|
rc: rc,
|
||||||
|
userID: userID,
|
||||||
|
provider: provider,
|
||||||
|
f: f,
|
||||||
|
}
|
||||||
|
return rcs.enqueueTask(ctx, rc.RemoteId, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendProfileImage is called when a sendProfileImageTask is popped from the send channel.
|
||||||
|
func (rcs *Service) sendProfileImage(task sendProfileImageTask) {
|
||||||
|
err := rcs.sendProfileImageToRemote(SendTimeout, task)
|
||||||
|
var response Response
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceError, "Remote Cluster send profile image failed",
|
||||||
|
mlog.String("remote", task.rc.DisplayName),
|
||||||
|
mlog.String("UserId", task.userID),
|
||||||
|
mlog.Err(err),
|
||||||
|
)
|
||||||
|
response.Status = ResponseStatusFail
|
||||||
|
response.Err = err.Error()
|
||||||
|
} else {
|
||||||
|
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceDebug, "Remote Cluster profile image sent successfully",
|
||||||
|
mlog.String("remote", task.rc.DisplayName),
|
||||||
|
mlog.String("UserId", task.userID),
|
||||||
|
)
|
||||||
|
response.Status = ResponseStatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// If callback provided then call it with the results.
|
||||||
|
if task.f != nil {
|
||||||
|
task.f(task.userID, task.rc, &response, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcs *Service) sendProfileImageToRemote(timeout time.Duration, task sendProfileImageTask) error {
|
||||||
|
rcs.server.GetLogger().Log(mlog.LvlRemoteClusterServiceDebug, "sending profile image to remote...",
|
||||||
|
mlog.String("remote", task.rc.DisplayName),
|
||||||
|
mlog.String("UserId", task.userID),
|
||||||
|
)
|
||||||
|
|
||||||
|
user, err := rcs.server.GetStore().User().Get(context.Background(), task.userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error fetching user while sending profile image to remote %s: %w", task.rc.RemoteId, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
img, _, appErr := task.provider.GetProfileImage(user) // get Reader for the file
|
||||||
|
if appErr != nil {
|
||||||
|
return fmt.Errorf("error fetching profile image for user (%s) while sending to remote %s: %w", task.userID, task.rc.RemoteId, appErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(task.rc.SiteURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid siteURL while sending file to remote %s: %w", task.rc.RemoteId, err)
|
||||||
|
}
|
||||||
|
u.Path = path.Join(u.Path, model.API_URL_SUFFIX, "remotecluster", task.userID, "image")
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
part, err := writer.CreateFormFile("image", "profile.png")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = io.Copy(part, bytes.NewBuffer(img)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = writer.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", u.String(), body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.Header.Set(model.HEADER_REMOTECLUSTER_ID, task.rc.RemoteId)
|
||||||
|
req.Header.Set(model.HEADER_REMOTECLUSTER_TOKEN, task.rc.RemoteToken)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := rcs.httpClient.Do(req.WithContext(ctx))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
_, err = ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("unexpected response: %d - %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
187
services/remotecluster/sendprofileImage_test.go
Обычный файл
187
services/remotecluster/sendprofileImage_test.go
Обычный файл
@@ -0,0 +1,187 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
package remotecluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
imageWidth = 128
|
||||||
|
imageHeight = 128
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestService_sendProfileImageToRemote(t *testing.T) {
|
||||||
|
hadPing := disablePing
|
||||||
|
disablePing = true
|
||||||
|
defer func() { disablePing = hadPing }()
|
||||||
|
|
||||||
|
shouldError := &flag{}
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer io.Copy(ioutil.Discard, r.Body)
|
||||||
|
|
||||||
|
if shouldError.get() {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
resp := make(map[string]string)
|
||||||
|
resp[model.STATUS] = model.STATUS_FAIL
|
||||||
|
w.Write([]byte(model.MapToJson(resp)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := model.STATUS_OK
|
||||||
|
defer func(s *string) {
|
||||||
|
if *s != model.STATUS_OK {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
resp := make(map[string]string)
|
||||||
|
resp[model.STATUS] = *s
|
||||||
|
w.Write([]byte(model.MapToJson(resp)))
|
||||||
|
}(&status)
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(1024 * 1024); err != nil {
|
||||||
|
status = model.STATUS_FAIL
|
||||||
|
assert.Fail(t, "connect parse multipart form", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := r.MultipartForm
|
||||||
|
if m == nil {
|
||||||
|
status = model.STATUS_FAIL
|
||||||
|
assert.Fail(t, "multipart form missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imageArray, ok := m.File["image"]
|
||||||
|
if !ok || len(imageArray) != 1 {
|
||||||
|
status = model.STATUS_FAIL
|
||||||
|
assert.Fail(t, "image missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imageData := imageArray[0]
|
||||||
|
file, err := imageData.Open()
|
||||||
|
if err != nil {
|
||||||
|
status = model.STATUS_FAIL
|
||||||
|
assert.Fail(t, "cannot open multipart form file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
img, err := png.Decode(file)
|
||||||
|
if err != nil || imageWidth != img.Bounds().Max.X || imageHeight != img.Bounds().Max.Y {
|
||||||
|
status = model.STATUS_FAIL
|
||||||
|
assert.Fail(t, "cannot decode png", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
rc := makeRemoteCluster("remote_test_profile_image", ts.URL, TestTopics)
|
||||||
|
|
||||||
|
user := &model.User{
|
||||||
|
Id: model.NewId(),
|
||||||
|
RemoteId: model.NewString(rc.RemoteId),
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := testImageProvider{}
|
||||||
|
|
||||||
|
mockServer := newMockServer(t, makeRemoteClusters(NumRemotes, ts.URL))
|
||||||
|
mockServer.SetUser(user)
|
||||||
|
service, err := NewRemoteClusterService(mockServer)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = service.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer service.Shutdown()
|
||||||
|
|
||||||
|
t.Run("Server response 200", func(t *testing.T) {
|
||||||
|
shouldError.set(false)
|
||||||
|
|
||||||
|
resultFunc := func(userId string, rc *model.RemoteCluster, resp *Response, err error) {
|
||||||
|
assert.Equal(t, user.Id, userId, "user ids should match")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, resp.IsSuccess())
|
||||||
|
}
|
||||||
|
|
||||||
|
task := sendProfileImageTask{
|
||||||
|
rc: rc,
|
||||||
|
userID: user.Id,
|
||||||
|
provider: provider,
|
||||||
|
f: resultFunc,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := service.sendProfileImageToRemote(time.Second*15, task)
|
||||||
|
assert.NoError(t, err, "request should not error")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Server response 500", func(t *testing.T) {
|
||||||
|
shouldError.set(true)
|
||||||
|
|
||||||
|
resultFunc := func(userId string, rc *model.RemoteCluster, resp *Response, err error) {
|
||||||
|
assert.Equal(t, user.Id, userId, "user ids should match")
|
||||||
|
assert.False(t, resp.IsSuccess())
|
||||||
|
}
|
||||||
|
|
||||||
|
task := sendProfileImageTask{
|
||||||
|
rc: rc,
|
||||||
|
userID: user.Id,
|
||||||
|
provider: provider,
|
||||||
|
f: resultFunc,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := service.sendProfileImageToRemote(time.Second*15, task)
|
||||||
|
assert.Error(t, err, "request should error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testImageProvider struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tip testImageProvider) GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) {
|
||||||
|
img := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{imageWidth, imageHeight}})
|
||||||
|
red := color.RGBA{255, 50, 50, 0xff}
|
||||||
|
|
||||||
|
for x := 0; x < imageWidth; x++ {
|
||||||
|
for y := 0; y < imageHeight; y++ {
|
||||||
|
img.Set(x, y, red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
png.Encode(buf, img)
|
||||||
|
|
||||||
|
return buf.Bytes(), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type flag struct {
|
||||||
|
mux sync.RWMutex
|
||||||
|
b bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flag) get() bool {
|
||||||
|
f.mux.RLock()
|
||||||
|
defer f.mux.RUnlock()
|
||||||
|
return f.b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *flag) set(b bool) {
|
||||||
|
f.mux.Lock()
|
||||||
|
defer f.mux.Unlock()
|
||||||
|
f.b = b
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ type RemoteClusterServiceIFace interface {
|
|||||||
RemoveConnectionStateListener(listenerId string)
|
RemoveConnectionStateListener(listenerId string)
|
||||||
SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f SendMsgResultFunc) error
|
SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f SendMsgResultFunc) error
|
||||||
SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp ReaderProvider, f SendFileResultFunc) error
|
SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp ReaderProvider, f SendFileResultFunc) error
|
||||||
|
SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider ProfileImageProvider, f SendProfileImageResultFunc) error
|
||||||
AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error)
|
AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error)
|
||||||
ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response
|
ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,38 @@ func (_m *MockAppIface) GetOrCreateDirectChannel(userId string, otherUserId stri
|
|||||||
return r0, r1
|
return r0, r1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProfileImage provides a mock function with given fields: user
|
||||||
|
func (_m *MockAppIface) GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) {
|
||||||
|
ret := _m.Called(user)
|
||||||
|
|
||||||
|
var r0 []byte
|
||||||
|
if rf, ok := ret.Get(0).(func(*model.User) []byte); ok {
|
||||||
|
r0 = rf(user)
|
||||||
|
} else {
|
||||||
|
if ret.Get(0) != nil {
|
||||||
|
r0 = ret.Get(0).([]byte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var r1 bool
|
||||||
|
if rf, ok := ret.Get(1).(func(*model.User) bool); ok {
|
||||||
|
r1 = rf(user)
|
||||||
|
} else {
|
||||||
|
r1 = ret.Get(1).(bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var r2 *model.AppError
|
||||||
|
if rf, ok := ret.Get(2).(func(*model.User) *model.AppError); ok {
|
||||||
|
r2 = rf(user)
|
||||||
|
} else {
|
||||||
|
if ret.Get(2) != nil {
|
||||||
|
r2 = ret.Get(2).(*model.AppError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0, r1, r2
|
||||||
|
}
|
||||||
|
|
||||||
// InvalidateCacheForUser provides a mock function with given fields: userID
|
// InvalidateCacheForUser provides a mock function with given fields: userID
|
||||||
func (_m *MockAppIface) InvalidateCacheForUser(userID string) {
|
func (_m *MockAppIface) InvalidateCacheForUser(userID string) {
|
||||||
_m.Called(userID)
|
_m.Called(userID)
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost-server/v5/model"
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||||
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
"github.com/mattermost/mattermost-server/v5/shared/mlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,26 +179,35 @@ func (scs *Service) usersForPost(post *model.Post, reactions []*model.Reaction,
|
|||||||
|
|
||||||
for _, id := range userIds {
|
for _, id := range userIds {
|
||||||
user, err := scs.server.GetStore().User().Get(context.Background(), id)
|
user, err := scs.server.GetStore().User().Get(context.Background(), id)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
if sync, err2 := scs.shouldUserSync(user, channelID, rc); err2 != nil {
|
|
||||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Could not find user for post",
|
|
||||||
mlog.String("user_id", id),
|
|
||||||
mlog.Err(err2),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
} else if sync {
|
|
||||||
users = append(users, sanitizeUserForSync(user))
|
|
||||||
}
|
|
||||||
// if this was a mention then put the real username in place of the username+remotename, but only
|
|
||||||
// when sending to the remote that the user belongs to.
|
|
||||||
if user.RemoteId != nil && *user.RemoteId == rc.RemoteId {
|
|
||||||
fixMention(post, mentionMap, user)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error checking if user should sync",
|
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error checking if user should sync",
|
||||||
mlog.String("user_id", id),
|
mlog.String("user_id", id),
|
||||||
mlog.Err(err),
|
mlog.Err(err),
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sync, syncImage, err2 := scs.shouldUserSync(user, channelID, rc)
|
||||||
|
if err2 != nil {
|
||||||
|
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Could not find user for post",
|
||||||
|
mlog.String("user_id", id),
|
||||||
|
mlog.Err(err2),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if sync {
|
||||||
|
users = append(users, sanitizeUserForSync(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
if syncImage {
|
||||||
|
scs.syncProfileImage(user, channelID, rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if this was a mention then put the real username in place of the username+remotename, but only
|
||||||
|
// when sending to the remote that the user belongs to.
|
||||||
|
if user.RemoteId != nil && *user.RemoteId == rc.RemoteId {
|
||||||
|
fixMention(post, mentionMap, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return users
|
return users
|
||||||
@@ -240,16 +251,16 @@ func sanitizeUserForSync(user *model.User) *model.User {
|
|||||||
// shouldUserSync determines if a user needs to be synchronized.
|
// shouldUserSync determines if a user needs to be synchronized.
|
||||||
// User should be synchronized if it has no entry in the SharedChannelUsers table for the specified channel,
|
// User should be synchronized if it has no entry in the SharedChannelUsers table for the specified channel,
|
||||||
// or there is an entry but the LastSyncAt is less than user.UpdateAt
|
// or there is an entry but the LastSyncAt is less than user.UpdateAt
|
||||||
func (scs *Service) shouldUserSync(user *model.User, channelID string, rc *model.RemoteCluster) (bool, error) {
|
func (scs *Service) shouldUserSync(user *model.User, channelID string, rc *model.RemoteCluster) (sync bool, syncImage bool, err error) {
|
||||||
// don't sync users with the remote they originated from.
|
// don't sync users with the remote they originated from.
|
||||||
if user.RemoteId != nil && *user.RemoteId == rc.RemoteId {
|
if user.RemoteId != nil && *user.RemoteId == rc.RemoteId {
|
||||||
return false, nil
|
return false, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
scu, err := scs.server.GetStore().SharedChannel().GetUser(user.Id, channelID, rc.RemoteId)
|
scu, err := scs.server.GetStore().SharedChannel().GetUser(user.Id, channelID, rc.RemoteId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := err.(errNotFound); !ok {
|
if _, ok := err.(errNotFound); !ok {
|
||||||
return false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// user not in the SharedChannelUsers table, so we must add them.
|
// user not in the SharedChannelUsers table, so we must add them.
|
||||||
@@ -266,8 +277,52 @@ func (scs *Service) shouldUserSync(user *model.User, channelID string, rc *model
|
|||||||
mlog.Err(err),
|
mlog.Err(err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if scu.LastSyncAt >= user.UpdateAt {
|
return true, true, nil
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
return true, nil
|
|
||||||
|
return user.UpdateAt > scu.LastSyncAt, user.LastPictureUpdate > scu.LastSyncAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scs *Service) syncProfileImage(user *model.User, channelID string, rc *model.RemoteCluster) {
|
||||||
|
rcs := scs.server.GetRemoteClusterService()
|
||||||
|
if rcs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rcs.SendProfileImage(ctx, user.Id, rc, scs.app, func(userId string, rc *model.RemoteCluster, resp *remotecluster.Response, err error) {
|
||||||
|
if resp.IsSuccess() {
|
||||||
|
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceDebug, "Users profile image synchronized",
|
||||||
|
mlog.String("remote_id", rc.RemoteId),
|
||||||
|
mlog.String("user_id", user.Id),
|
||||||
|
)
|
||||||
|
|
||||||
|
scu, err := scs.server.GetStore().SharedChannel().GetUser(user.Id, channelID, rc.RemoteId)
|
||||||
|
if err != nil {
|
||||||
|
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error fetching shared channel user while updating users LastSyncTime after profile image update",
|
||||||
|
mlog.String("remote_id", rc.RemoteId),
|
||||||
|
mlog.String("user_id", user.Id),
|
||||||
|
mlog.String("channel_id", channelID),
|
||||||
|
mlog.Err(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = scs.server.GetStore().SharedChannel().UpdateUserLastSyncAt(scu.Id, model.GetMillis()); err != nil {
|
||||||
|
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error updating users LastSyncTime after profile image update",
|
||||||
|
mlog.String("remote_id", rc.RemoteId),
|
||||||
|
mlog.String("user_id", user.Id),
|
||||||
|
mlog.Err(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scs.server.GetLogger().Log(mlog.LvlSharedChannelServiceError, "Error synchronizing users profile image",
|
||||||
|
mlog.String("remote_id", rc.RemoteId),
|
||||||
|
mlog.String("user_id", user.Id),
|
||||||
|
mlog.String("Err", resp.Err),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type AppIface interface {
|
|||||||
CreateUploadSession(us *model.UploadSession) (*model.UploadSession, *model.AppError)
|
CreateUploadSession(us *model.UploadSession) (*model.UploadSession, *model.AppError)
|
||||||
FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError)
|
FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError)
|
||||||
MentionsToTeamMembers(message, teamID string) model.UserMentionMap
|
MentionsToTeamMembers(message, teamID string) model.UserMentionMap
|
||||||
|
GetProfileImage(user *model.User) ([]byte, bool, *model.AppError)
|
||||||
InvalidateCacheForUser(userID string)
|
InvalidateCacheForUser(userID string)
|
||||||
NotifySharedChannelUserUpdate(user *model.User)
|
NotifySharedChannelUserUpdate(user *model.User)
|
||||||
}
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user