Merge branch 'master' into mm-1420

Этот коммит содержится в:
=Corey Hulen
2015-07-22 18:05:44 -08:00
родитель e0bad6c037 f4c3eaa8c0
Коммит c277a98b5d
44 изменённых файлов: 1069 добавлений и 435 удалений

4
.gitignore поставляемый
Просмотреть файл

@@ -47,3 +47,7 @@ web/sass-files/sass/.sass-cache/
*config.codekit *config.codekit
*.sass-cache *.sass-cache
*styles.css *styles.css
# Default local file storage
data/*
api/data/*

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

@@ -20,6 +20,11 @@ before_install:
- "sudo sed -i'' 's/basedir[^=]\\+=.*$/basedir = \\/opt\\/mysql\\/server-5.6/' /etc/mysql/my.cnf" - "sudo sed -i'' 's/basedir[^=]\\+=.*$/basedir = \\/opt\\/mysql\\/server-5.6/' /etc/mysql/my.cnf"
- "sudo /etc/init.d/mysql.server start" - "sudo /etc/init.d/mysql.server start"
install:
- export PATH=$PATH:$HOME/gopath/bin
- go get github.com/tools/godep
- godep restore
before_script: before_script:
- mysql -e "CREATE DATABASE IF NOT EXISTS mattermost_test ;" -uroot - mysql -e "CREATE DATABASE IF NOT EXISTS mattermost_test ;" -uroot
- mysql -e "CREATE USER 'mmuser'@'%' IDENTIFIED BY 'mostest' ;" -uroot - mysql -e "CREATE USER 'mmuser'@'%' IDENTIFIED BY 'mostest' ;" -uroot

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

@@ -40,7 +40,7 @@ WORKDIR /go
# #
# Install SQL # Install SQL
# #
ENV MYSQL_ROOT_PASSWORD=mostest ENV MYSQL_ROOT_PASSWORD=mostest
ENV MYSQL_USER=mmuser ENV MYSQL_USER=mmuser
@@ -60,7 +60,7 @@ RUN echo "deb http://repo.mysql.com/apt/debian/ wheezy mysql-${MYSQL_MAJOR}" > /
RUN apt-get update \ RUN apt-get update \
&& export DEBIAN_FRONTEND=noninteractive \ && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install mysql-server \ && apt-get -y install mysql-server \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& rm -rf /var/lib/mysql && mkdir -p /var/lib/mysql && rm -rf /var/lib/mysql && mkdir -p /var/lib/mysql
@@ -88,12 +88,15 @@ ADD . /go/src/github.com/mattermost/platform
ADD ./docker/main.cf /etc/postfix/ ADD ./docker/main.cf /etc/postfix/
RUN go get github.com/tools/godep RUN go get github.com/tools/godep
RUN cd /go/src/github.com/mattermost/platform; godep restore RUN cd /go/src/github.com/mattermost/platform; godep restore
RUN go install github.com/mattermost/platform RUN go install github.com/mattermost/platform
RUN cd /go/src/github.com/mattermost/platform/web/react; npm install RUN cd /go/src/github.com/mattermost/platform/web/react; npm install
RUN chmod +x /go/src/github.com/mattermost/platform/docker/docker-entry.sh RUN chmod +x /go/src/github.com/mattermost/platform/docker/docker-entry.sh
ENTRYPOINT /go/src/github.com/mattermost/platform/docker/docker-entry.sh ENTRYPOINT /go/src/github.com/mattermost/platform/docker/docker-entry.sh
# Create default storage directory
RUN mkdir /mattermost/
# Ports # Ports
EXPOSE 80 EXPOSE 80

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

@@ -112,6 +112,8 @@ clean:
rm -f web/static/js/bundle*.js rm -f web/static/js/bundle*.js
rm -f web/static/css/styles.css rm -f web/static/css/styles.css
rm -rf data/*
rm -rf api/data/*
rm -rf logs/* rm -rf logs/*

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

@@ -18,8 +18,10 @@ import (
_ "image/gif" _ "image/gif"
"image/jpeg" "image/jpeg"
"io" "io"
"io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -27,7 +29,7 @@ import (
) )
func InitFile(r *mux.Router) { func InitFile(r *mux.Router) {
l4g.Debug("Initializing post api routes") l4g.Debug("Initializing file api routes")
sr := r.PathPrefix("/files").Subrouter() sr := r.PathPrefix("/files").Subrouter()
sr.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST") sr.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST")
@@ -36,8 +38,8 @@ func InitFile(r *mux.Router) {
} }
func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
if !utils.IsS3Configured() { if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
c.Err = model.NewAppError("uploadFile", "Unable to upload file. Amazon S3 not configured. ", "") c.Err = model.NewAppError("uploadFile", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "")
c.Err.StatusCode = http.StatusNotImplemented c.Err.StatusCode = http.StatusNotImplemented
return return
} }
@@ -48,13 +50,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} }
var auth aws.Auth
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
m := r.MultipartForm m := r.MultipartForm
props := m.Value props := m.Value
@@ -94,28 +89,25 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
io.Copy(buf, file) io.Copy(buf, file)
ext := filepath.Ext(files[i].Filename) filename := filepath.Base(files[i].Filename)
uid := model.NewId() uid := model.NewId()
path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + files[i].Filename path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename
if model.IsFileExtImage(ext) { if err := writeFile(buf.Bytes(), path); err != nil {
options := s3.Options{} c.Err = err
err = bucket.Put(path, buf.Bytes(), model.GetImageMimeType(ext), s3.Private, options)
imageNameList = append(imageNameList, uid+"/"+files[i].Filename)
imageDataList = append(imageDataList, buf.Bytes())
} else {
options := s3.Options{}
err = bucket.Put(path, buf.Bytes(), "binary/octet-stream", s3.Private, options)
}
if err != nil {
c.Err = model.NewAppError("uploadFile", "Unable to upload file. ", err.Error())
return return
} }
fileUrl := c.GetSiteURL() + "/api/v1/files/get/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + url.QueryEscape(files[i].Filename) if model.IsFileExtImage(filepath.Ext(files[i].Filename)) {
imageNameList = append(imageNameList, uid+"/"+filename)
imageDataList = append(imageDataList, buf.Bytes())
}
encName := utils.UrlEncode(filename)
fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName
resStruct.Filenames = append(resStruct.Filenames, fileUrl) resStruct.Filenames = append(resStruct.Filenames, fileUrl)
} }
@@ -127,13 +119,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, channelId, userId string) { func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, channelId, userId string) {
go func() { go func() {
var auth aws.Auth
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
dest := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" dest := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/"
for i, filename := range filenames { for i, filename := range filenames {
@@ -169,11 +154,8 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch
return return
} }
// Upload thumbnail to S3 if err := writeFile(buf.Bytes(), dest+name+"_thumb.jpg"); err != nil {
options := s3.Options{} l4g.Error("Unable to upload thumbnail channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err)
err = bucket.Put(dest+name+"_thumb.jpg", buf.Bytes(), "image/jpeg", s3.Private, options)
if err != nil {
l4g.Error("Unable to upload thumbnail to S3 channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err)
return return
} }
}() }()
@@ -188,19 +170,15 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
err = jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90})
//err = png.Encode(buf, preview) err = jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90})
if err != nil { if err != nil {
l4g.Error("Unable to encode image as preview jpg channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) l4g.Error("Unable to encode image as preview jpg channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err)
return return
} }
// Upload preview to S3 if err := writeFile(buf.Bytes(), dest+name+"_preview.jpg"); err != nil {
options := s3.Options{} l4g.Error("Unable to upload preview channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err)
err = bucket.Put(dest+name+"_preview.jpg", buf.Bytes(), "image/jpeg", s3.Private, options)
if err != nil {
l4g.Error("Unable to upload preview to S3 channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err)
return return
} }
}() }()
@@ -215,8 +193,8 @@ type ImageGetResult struct {
} }
func getFile(c *Context, w http.ResponseWriter, r *http.Request) { func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
if !utils.IsS3Configured() { if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
c.Err = model.NewAppError("getFile", "Unable to get file. Amazon S3 not configured. ", "") c.Err = model.NewAppError("getFile", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "")
c.Err.StatusCode = http.StatusNotImplemented c.Err.StatusCode = http.StatusNotImplemented
return return
} }
@@ -247,13 +225,6 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId) cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId)
var auth aws.Auth
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
path := "" path := ""
if len(teamId) == 26 { if len(teamId) == 26 {
path = "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename path = "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
@@ -262,7 +233,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
} }
fileData := make(chan []byte) fileData := make(chan []byte)
asyncGetFile(bucket, path, fileData) asyncGetFile(path, fileData)
if len(hash) > 0 && len(data) > 0 && len(teamId) == 26 { if len(hash) > 0 && len(data) > 0 && len(teamId) == 26 {
if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.ServiceSettings.PublicLinkSalt)) { if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.ServiceSettings.PublicLinkSalt)) {
@@ -283,26 +254,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
f := <-fileData f := <-fileData
if f == nil { if f == nil {
var f2 []byte c.Err = model.NewAppError("getFile", "Could not find file.", "path="+path)
tries := 0
for {
time.Sleep(3000 * time.Millisecond)
tries++
asyncGetFile(bucket, path, fileData)
f2 = <-fileData
if f2 != nil {
w.Header().Set("Cache-Control", "max-age=2592000, public")
w.Header().Set("Content-Length", strconv.Itoa(len(f2)))
w.Write(f2)
return
} else if tries >= 2 {
break
}
}
c.Err = model.NewAppError("getFile", "Could not find file.", "url extenstion: "+path)
c.Err.StatusCode = http.StatusNotFound c.Err.StatusCode = http.StatusNotFound
return return
} }
@@ -312,10 +264,11 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write(f) w.Write(f)
} }
func asyncGetFile(bucket *s3.Bucket, path string, fileData chan []byte) { func asyncGetFile(path string, fileData chan []byte) {
go func() { go func() {
data, getErr := bucket.Get(path) data, getErr := readFile(path)
if getErr != nil { if getErr != nil {
l4g.Error(getErr)
fileData <- nil fileData <- nil
} else { } else {
fileData <- data fileData <- data
@@ -329,8 +282,8 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err.StatusCode = http.StatusForbidden c.Err.StatusCode = http.StatusForbidden
} }
if !utils.IsS3Configured() { if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
c.Err = model.NewAppError("getPublicLink", "Unable to get link. Amazon S3 not configured. ", "") c.Err = model.NewAppError("getPublicLink", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "")
c.Err.StatusCode = http.StatusNotImplemented c.Err.StatusCode = http.StatusNotImplemented
return return
} }
@@ -344,15 +297,14 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
} }
matches := model.PartialUrlRegex.FindAllStringSubmatch(filename, -1) matches := model.PartialUrlRegex.FindAllStringSubmatch(filename, -1)
if len(matches) == 0 || len(matches[0]) < 5 { if len(matches) == 0 || len(matches[0]) < 4 {
c.SetInvalidParam("getPublicLink", "filename") c.SetInvalidParam("getPublicLink", "filename")
return return
} }
getType := matches[0][1] channelId := matches[0][1]
channelId := matches[0][2] userId := matches[0][2]
userId := matches[0][3] filename = matches[0][3]
filename = matches[0][4]
cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId) cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId)
@@ -363,7 +315,7 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
data := model.MapToJson(newProps) data := model.MapToJson(newProps)
hash := model.HashPassword(fmt.Sprintf("%v:%v", data, utils.Cfg.ServiceSettings.PublicLinkSalt)) hash := model.HashPassword(fmt.Sprintf("%v:%v", data, utils.Cfg.ServiceSettings.PublicLinkSalt))
url := fmt.Sprintf("%s/api/v1/files/%s/%s/%s/%s?d=%s&h=%s&t=%s", c.GetSiteURL(), getType, channelId, userId, filename, url.QueryEscape(data), url.QueryEscape(hash), c.Session.TeamId) url := fmt.Sprintf("%s/api/v1/files/get/%s/%s/%s?d=%s&h=%s&t=%s", c.GetSiteURL(), channelId, userId, filename, url.QueryEscape(data), url.QueryEscape(hash), c.Session.TeamId)
if !c.HasPermissionsToChannel(cchan, "getPublicLink") { if !c.HasPermissionsToChannel(cchan, "getPublicLink") {
return return
@@ -374,3 +326,78 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(model.MapToJson(rData))) w.Write([]byte(model.MapToJson(rData)))
} }
func writeFile(f []byte, path string) *model.AppError {
if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
var auth aws.Auth
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
ext := filepath.Ext(path)
var err error
if model.IsFileExtImage(ext) {
options := s3.Options{}
err = bucket.Put(path, f, model.GetImageMimeType(ext), s3.Private, options)
} else {
options := s3.Options{}
err = bucket.Put(path, f, "binary/octet-stream", s3.Private, options)
}
if err != nil {
return model.NewAppError("writeFile", "Encountered an error writing to S3", err.Error())
}
} else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 {
if err := os.MkdirAll(filepath.Dir(utils.Cfg.ServiceSettings.StorageDirectory+path), 0774); err != nil {
return model.NewAppError("writeFile", "Encountered an error creating the directory for the new file", err.Error())
}
if err := ioutil.WriteFile(utils.Cfg.ServiceSettings.StorageDirectory+path, f, 0644); err != nil {
return model.NewAppError("writeFile", "Encountered an error writing to local server storage", err.Error())
}
} else {
return model.NewAppError("writeFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "")
}
return nil
}
func readFile(path string) ([]byte, *model.AppError) {
if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
var auth aws.Auth
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
// try to get the file from S3 with some basic retry logic
tries := 0
for {
tries++
f, err := bucket.Get(path)
if f != nil {
return f, nil
} else if tries >= 3 {
return nil, model.NewAppError("readFile", "Unable to get file from S3", "path="+path+", err="+err.Error())
}
time.Sleep(3000 * time.Millisecond)
}
} else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 {
if f, err := ioutil.ReadFile(utils.Cfg.ServiceSettings.StorageDirectory + path); err != nil {
return nil, model.NewAppError("readFile", "Encountered an error reading from local server storage", err.Error())
} else {
return f, nil
}
} else {
return nil, model.NewAppError("readFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "")
}
}

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

@@ -38,7 +38,7 @@ func TestUploadFile(t *testing.T) {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("files", "test.png") part, err := writer.CreateFormFile("files", "../test.png")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -68,12 +68,17 @@ func TestUploadFile(t *testing.T) {
} }
resp, appErr := Client.UploadFile("/files/upload", body.Bytes(), writer.FormDataContentType()) resp, appErr := Client.UploadFile("/files/upload", body.Bytes(), writer.FormDataContentType())
if utils.IsS3Configured() { if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
if appErr != nil { if appErr != nil {
t.Fatal(appErr) t.Fatal(appErr)
} }
filenames := resp.Data.(*model.FileUploadResponse).Filenames filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
if strings.Contains(filename, "../") {
t.Fatal("relative path should have been sanitized out")
}
fileId := strings.Split(filename, ".")[0]
var auth aws.Auth var auth aws.Auth
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
@@ -82,12 +87,10 @@ func TestUploadFile(t *testing.T) {
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
fileId := strings.Split(filenames[0], ".")[0]
// wait a bit for files to ready // wait a bit for files to ready
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filenames[0]) err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -97,13 +100,38 @@ func TestUploadFile(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.png") err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
} else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 {
filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
if strings.Contains(filename, "../") {
t.Fatal("relative path should have been sanitized out")
}
fileId := strings.Split(filename, ".")[0]
// wait a bit for files to ready
time.Sleep(5 * time.Second)
path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg"
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg"
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
} else { } else {
if appErr == nil { if appErr == nil {
t.Fatal("S3 not configured, should have failed") t.Fatal("S3 and local storage not configured, should have failed")
} }
} }
} }
@@ -123,7 +151,7 @@ func TestGetFile(t *testing.T) {
channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
if utils.IsS3Configured() { if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
@@ -169,8 +197,8 @@ func TestGetFile(t *testing.T) {
// wait a bit for files to ready // wait a bit for files to ready
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
if _, downErr := Client.GetFile(filenames[0], true); downErr != nil { if _, downErr := Client.GetFile(filenames[0], false); downErr != nil {
t.Fatal("file get failed") t.Fatal(downErr)
} }
team2 := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} team2 := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN}
@@ -189,35 +217,35 @@ func TestGetFile(t *testing.T) {
Client.LoginByEmail(team2.Name, user2.Email, "pwd") Client.LoginByEmail(team2.Name, user2.Email, "pwd")
if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t="+team.Id, true); downErr != nil { if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t="+team.Id, false); downErr != nil {
t.Fatal(downErr) t.Fatal(downErr)
} }
if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash), true); downErr == nil { if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash), false); downErr == nil {
t.Fatal("Should have errored - missing team id") t.Fatal("Should have errored - missing team id")
} }
if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t=junk", true); downErr == nil { if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t=junk", false); downErr == nil {
t.Fatal("Should have errored - bad team id") t.Fatal("Should have errored - bad team id")
} }
if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t=12345678901234567890123456", true); downErr == nil { if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h="+url.QueryEscape(hash)+"&t=12345678901234567890123456", false); downErr == nil {
t.Fatal("Should have errored - bad team id") t.Fatal("Should have errored - bad team id")
} }
if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&t="+team.Id, true); downErr == nil { if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&t="+team.Id, false); downErr == nil {
t.Fatal("Should have errored - missing hash") t.Fatal("Should have errored - missing hash")
} }
if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h=junk&t="+team.Id, true); downErr == nil { if _, downErr := Client.GetFile(filenames[0]+"?d="+url.QueryEscape(data)+"&h=junk&t="+team.Id, false); downErr == nil {
t.Fatal("Should have errored - bad hash") t.Fatal("Should have errored - bad hash")
} }
if _, downErr := Client.GetFile(filenames[0]+"?h="+url.QueryEscape(hash)+"&t="+team.Id, true); downErr == nil { if _, downErr := Client.GetFile(filenames[0]+"?h="+url.QueryEscape(hash)+"&t="+team.Id, false); downErr == nil {
t.Fatal("Should have errored - missing data") t.Fatal("Should have errored - missing data")
} }
if _, downErr := Client.GetFile(filenames[0]+"?d=junk&h="+url.QueryEscape(hash)+"&t="+team.Id, true); downErr == nil { if _, downErr := Client.GetFile(filenames[0]+"?d=junk&h="+url.QueryEscape(hash)+"&t="+team.Id, false); downErr == nil {
t.Fatal("Should have errored - bad data") t.Fatal("Should have errored - bad data")
} }
@@ -225,28 +253,51 @@ func TestGetFile(t *testing.T) {
t.Fatal("Should have errored - user not logged in and link not public") t.Fatal("Should have errored - user not logged in and link not public")
} }
var auth aws.Auth if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId var auth aws.Auth
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
fileId := strings.Split(filenames[0], ".")[0] filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
fileId := strings.Split(filename, ".")[0]
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filenames[0]) err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg") err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.png") err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
}
} else {
filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
fileId := strings.Split(filename, ".")[0]
path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg"
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg"
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
} }
} else { } else {
if _, downErr := Client.GetFile("/files/get/yxebdmbz5pgupx7q6ez88rw11a/n3btzxu9hbnapqk36iwaxkjxhc/junk.jpg", false); downErr.StatusCode != http.StatusNotImplemented { if _, downErr := Client.GetFile("/files/get/yxebdmbz5pgupx7q6ez88rw11a/n3btzxu9hbnapqk36iwaxkjxhc/junk.jpg", false); downErr.StatusCode != http.StatusNotImplemented {
@@ -274,7 +325,7 @@ func TestGetPublicLink(t *testing.T) {
channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
if utils.IsS3Configured() { if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
@@ -350,26 +401,52 @@ func TestGetPublicLink(t *testing.T) {
t.Fatal("should have errored, user not member of channel") t.Fatal("should have errored, user not member of channel")
} }
// perform clean-up on s3 if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
var auth aws.Auth // perform clean-up on s3
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId var auth aws.Auth
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
fileId := strings.Split(filenames[0], ".")[0] filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
fileId := strings.Split(filename, ".")[0]
if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + filenames[0]); err != nil { err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename)
t.Fatal(err) if err != nil {
} t.Fatal(err)
}
if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + fileId + "_thumb.jpg"); err != nil { err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg")
t.Fatal(err) if err != nil {
} t.Fatal(err)
}
if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + fileId + "_preview.png"); err != nil { err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg")
t.Fatal(err) if err != nil {
t.Fatal(err)
}
} else {
filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/")
filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1]
fileId := strings.Split(filename, ".")[0]
path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg"
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg"
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
} }
} else { } else {
data := make(map[string]string) data := make(map[string]string)

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

@@ -11,6 +11,7 @@ import (
"github.com/mattermost/platform/store" "github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils" "github.com/mattermost/platform/utils"
"net/http" "net/http"
"net/url"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -170,16 +171,16 @@ func CreatePost(c *Context, post *model.Post, doUpdateLastViewed bool) (*model.P
continue continue
} else if model.PartialUrlRegex.MatchString(path) { } else if model.PartialUrlRegex.MatchString(path) {
matches := model.PartialUrlRegex.FindAllStringSubmatch(path, -1) matches := model.PartialUrlRegex.FindAllStringSubmatch(path, -1)
if len(matches) == 0 || len(matches[0]) < 5 { if len(matches) == 0 || len(matches[0]) < 4 {
doRemove = true doRemove = true
} }
channelId := matches[0][2] channelId := matches[0][1]
if channelId != post.ChannelId { if channelId != post.ChannelId {
doRemove = true doRemove = true
} }
userId := matches[0][3] userId := matches[0][2]
if userId != post.UserId { if userId != post.UserId {
doRemove = true doRemove = true
} }
@@ -407,6 +408,36 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) {
bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message) bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message)
bodyPage.Props["TeamLink"] = teamURL + "/channels/" + channel.Name bodyPage.Props["TeamLink"] = teamURL + "/channels/" + channel.Name
// attempt to fill in a message body if the post doesn't have any text
if len(strings.TrimSpace(bodyPage.Props["PostMessage"])) == 0 && len(post.Filenames) > 0 {
// extract the filenames from their paths and determine what type of files are attached
filenames := make([]string, len(post.Filenames))
onlyImages := true
for i, filename := range post.Filenames {
var err error
if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil {
// this should never error since filepath was escaped using url.QueryEscape
filenames[i] = filepath.Base(filename)
}
ext := filepath.Ext(filename)
onlyImages = onlyImages && model.IsFileExtImage(ext)
}
filenamesString := strings.Join(filenames, ", ")
var attachmentPrefix string
if onlyImages {
attachmentPrefix = "Image"
} else {
attachmentPrefix = "File"
}
if len(post.Filenames) > 1 {
attachmentPrefix += "s"
}
bodyPage.Props["PostMessage"] = fmt.Sprintf("%s: %s sent", attachmentPrefix, filenamesString)
}
if err := utils.SendMail(profileMap[id].Email, subjectPage.Render(), bodyPage.Render()); err != nil { if err := utils.SendMail(profileMap[id].Email, subjectPage.Render(), bodyPage.Render()); err != nil {
l4g.Error("Failed to send mention email successfully email=%v err=%v", profileMap[id].Email, err) l4g.Error("Failed to send mention email successfully email=%v err=%v", profileMap[id].Email, err)
} }

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

@@ -37,7 +37,7 @@ func TestCreatePost(t *testing.T) {
channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id}
channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel) channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel)
filenames := []string{"/api/v1/files/get/12345678901234567890123456/12345678901234567890123456/test.png", "/api/v1/files/get/" + channel1.Id + "/" + user1.Id + "/test.png", "www.mattermost.com/fake/url", "junk"} filenames := []string{"/12345678901234567890123456/12345678901234567890123456/12345678901234567890123456/test.png", "/" + channel1.Id + "/" + user1.Id + "/test.png", "www.mattermost.com/fake/url", "junk"}
post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a", Filenames: filenames} post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a", Filenames: filenames}
rpost1, err := Client.CreatePost(post1) rpost1, err := Client.CreatePost(post1)

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

@@ -1 +1 @@
{{define "post_subject"}}[{{.Props.TeamDisplayName}} {{.SiteName}}] {{.Props.SubjectText}} for {{.Props.Month}} {{.Props.Day}}, {{.Props.Year}}{{end}} {{define "post_subject"}}[{{.SiteName}}] {{.Props.TeamDisplayName}} Team Notifications for {{.Props.Month}} {{.Props.Day}}, {{.Props.Year}}{{end}}

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

@@ -7,8 +7,6 @@ import (
"bytes" "bytes"
l4g "code.google.com/p/log4go" l4g "code.google.com/p/log4go"
"fmt" "fmt"
"github.com/goamz/goamz/aws"
"github.com/goamz/goamz/s3"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/mattermost/platform/model" "github.com/mattermost/platform/model"
"github.com/mattermost/platform/store" "github.com/mattermost/platform/store"
@@ -598,7 +596,7 @@ func createProfileImage(username string, userId string) ([]byte, *model.AppError
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
if imgErr := png.Encode(buf, img); imgErr != nil { if imgErr := png.Encode(buf, img); imgErr != nil {
return nil, model.NewAppError("getProfileImage", "Could not encode default profile image", imgErr.Error()) return nil, model.NewAppError("createProfileImage", "Could not encode default profile image", imgErr.Error())
} else { } else {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
@@ -613,34 +611,25 @@ func getProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} else { } else {
var img []byte var img []byte
var err *model.AppError
if !utils.IsS3Configured() { if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
img, err = createProfileImage(result.Data.(*model.User).Username, id) var err *model.AppError
if err != nil { if img, err = createProfileImage(result.Data.(*model.User).Username, id); err != nil {
c.Err = err c.Err = err
return return
} }
} else { } else {
var auth aws.Auth
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
path := "teams/" + c.Session.TeamId + "/users/" + id + "/profile.png" path := "teams/" + c.Session.TeamId + "/users/" + id + "/profile.png"
if data, getErr := bucket.Get(path); getErr != nil { if data, err := readFile(path); err != nil {
img, err = createProfileImage(result.Data.(*model.User).Username, id)
if err != nil { if img, err = createProfileImage(result.Data.(*model.User).Username, id); err != nil {
c.Err = err c.Err = err
return return
} }
options := s3.Options{} if err := writeFile(img, path); err != nil {
if err := bucket.Put(path, img, "image", s3.Private, options); err != nil { c.Err = err
c.Err = model.NewAppError("getImage", "Couldn't upload default profile image", err.Error())
return return
} }
@@ -660,8 +649,8 @@ func getProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
} }
func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
if !utils.IsS3Configured() { if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
c.Err = model.NewAppError("uploadProfileImage", "Unable to upload image. Amazon S3 not configured. ", "") c.Err = model.NewAppError("uploadProfileImage", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "")
c.Err.StatusCode = http.StatusNotImplemented c.Err.StatusCode = http.StatusNotImplemented
return return
} }
@@ -671,13 +660,6 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
return return
} }
var auth aws.Auth
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
m := r.MultipartForm m := r.MultipartForm
imageArray, ok := m.File["image"] imageArray, ok := m.File["image"]
@@ -721,8 +703,7 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
path := "teams/" + c.Session.TeamId + "/users/" + c.Session.UserId + "/profile.png" path := "teams/" + c.Session.TeamId + "/users/" + c.Session.UserId + "/profile.png"
options := s3.Options{} if err := writeFile(buf.Bytes(), path); err != nil {
if err := bucket.Put(path, buf.Bytes(), "image", s3.Private, options); err != nil {
c.Err = model.NewAppError("uploadProfileImage", "Couldn't upload profile image", "") c.Err = model.NewAppError("uploadProfileImage", "Couldn't upload profile image", "")
return return
} }

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

@@ -363,6 +363,24 @@ func TestUserCreateImage(t *testing.T) {
Client.DoGet("/users/"+user.Id+"/image", "", "") Client.DoGet("/users/"+user.Id+"/image", "", "")
if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
var auth aws.Auth
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
if err := bucket.Del("teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"); err != nil {
t.Fatal(err)
}
} else {
path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
}
} }
func TestUserUploadProfileImage(t *testing.T) { func TestUserUploadProfileImage(t *testing.T) {
@@ -375,7 +393,7 @@ func TestUserUploadProfileImage(t *testing.T) {
user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
store.Must(Srv.Store.User().VerifyEmail(user.Id)) store.Must(Srv.Store.User().VerifyEmail(user.Id))
if utils.IsS3Configured() { if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
@@ -443,15 +461,22 @@ func TestUserUploadProfileImage(t *testing.T) {
Client.DoGet("/users/"+user.Id+"/image", "", "") Client.DoGet("/users/"+user.Id+"/image", "", "")
var auth aws.Auth if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId var auth aws.Auth
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
if err := bucket.Del("teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"); err != nil { if err := bucket.Del("teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"); err != nil {
t.Fatal(err) t.Fatal(err)
}
} else {
path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"
if err := os.Remove(path); err != nil {
t.Fatal("Couldn't remove file at " + path)
}
} }
} else { } else {
body := &bytes.Buffer{} body := &bytes.Buffer{}

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

@@ -19,7 +19,9 @@
"InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6", "InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6",
"PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4",
"ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t",
"AnalyticsUrl": "" "AnalyticsUrl": "",
"UseLocalStorage": true,
"StorageDirectory": "./data/"
}, },
"SqlSettings": { "SqlSettings": {
"DriverName": "mysql", "DriverName": "mysql",

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

@@ -19,7 +19,9 @@
"InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6", "InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6",
"PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4",
"ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t",
"AnalyticsUrl": "" "AnalyticsUrl": "",
"UseLocalStorage": true,
"StorageDirectory": "/mattermost/data/"
}, },
"SqlSettings": { "SqlSettings": {
"DriverName": "mysql", "DriverName": "mysql",

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

@@ -552,7 +552,7 @@ func (c *Client) GetFile(url string, isFullUrl bool) (*Result, *AppError) {
if isFullUrl { if isFullUrl {
rq, _ = http.NewRequest("GET", url, nil) rq, _ = http.NewRequest("GET", url, nil)
} else { } else {
rq, _ = http.NewRequest("GET", c.Url+url, nil) rq, _ = http.NewRequest("GET", c.Url+"/files/get"+url, nil)
} }
if len(c.AuthToken) > 0 { if len(c.AuthToken) > 0 {

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

@@ -319,6 +319,6 @@ func ClearMentionTags(post string) string {
} }
var UrlRegex = regexp.MustCompile(`^((?:[a-z]+:\/\/)?(?:(?:[a-z0-9\-]+\.)+(?:[a-z]{2}|aero|arpa|biz|com|coop|edu|gov|info|int|jobs|mil|museum|name|nato|net|org|pro|travel|local|internal))(:[0-9]{1,5})?(?:\/[a-z0-9_\-\.~]+)*(\/([a-z0-9_\-\.]*)(?:\?[a-z0-9+_~\-\.%=&amp;]*)?)?(?:#[a-zA-Z0-9!$&'()*+.=-_~:@/?]*)?)(?:\s+|$)$`) var UrlRegex = regexp.MustCompile(`^((?:[a-z]+:\/\/)?(?:(?:[a-z0-9\-]+\.)+(?:[a-z]{2}|aero|arpa|biz|com|coop|edu|gov|info|int|jobs|mil|museum|name|nato|net|org|pro|travel|local|internal))(:[0-9]{1,5})?(?:\/[a-z0-9_\-\.~]+)*(\/([a-z0-9_\-\.]*)(?:\?[a-z0-9+_~\-\.%=&amp;]*)?)?(?:#[a-zA-Z0-9!$&'()*+.=-_~:@/?]*)?)(?:\s+|$)$`)
var PartialUrlRegex = regexp.MustCompile(`/api/v1/files/(get|get_image)/([A-Za-z0-9]{26})/([A-Za-z0-9]{26})/(([A-Za-z0-9]+/)?.+\.[A-Za-z0-9]{3,})`) var PartialUrlRegex = regexp.MustCompile(`/([A-Za-z0-9]{26})/([A-Za-z0-9]{26})/((?:[A-Za-z0-9]{26})?.+\.[A-Za-z0-9]{3,})`)
var SplitRunes = map[rune]bool{',': true, ' ': true, '.': true, '!': true, '?': true, ':': true, ';': true, '\n': true, '<': true, '>': true, '(': true, ')': true, '{': true, '}': true, '[': true, ']': true, '+': true, '/': true, '\\': true} var SplitRunes = map[rune]bool{',': true, ' ': true, '.': true, '!': true, '?': true, ':': true, ';': true, '\n': true, '<': true, '>': true, '(': true, ')': true, '{': true, '}': true, '[': true, ']': true, '+': true, '/': true, '\\': true}

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

@@ -18,16 +18,18 @@ const (
) )
type ServiceSettings struct { type ServiceSettings struct {
SiteName string SiteName string
Mode string Mode string
AllowTesting bool AllowTesting bool
UseSSL bool UseSSL bool
Port string Port string
Version string Version string
InviteSalt string InviteSalt string
PublicLinkSalt string PublicLinkSalt string
ResetSalt string ResetSalt string
AnalyticsUrl string AnalyticsUrl string
UseLocalStorage bool
StorageDirectory string
} }
type SqlSettings struct { type SqlSettings struct {

19
utils/urlencode.go Обычный файл
Просмотреть файл

@@ -0,0 +1,19 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
package utils
import (
"net/url"
"strings"
)
func UrlEncode(str string) string {
strs := strings.Split(str, " ")
for i, s := range strs {
strs[i] = url.QueryEscape(s)
}
return strings.Join(strs, "%20")
}

100
web/react/components/access_history_modal.jsx Обычный файл
Просмотреть файл

@@ -0,0 +1,100 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
var UserStore = require('../stores/user_store.jsx');
var AsyncClient = require('../utils/async_client.jsx');
var Utils = require('../utils/utils.jsx');
function getStateFromStoresForAudits() {
return {
audits: UserStore.getAudits()
};
}
module.exports = React.createClass({
componentDidMount: function() {
UserStore.addAuditsChangeListener(this._onChange);
AsyncClient.getAudits();
var self = this;
$(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) {
self.setState({ moreInfo: [] });
});
},
componentWillUnmount: function() {
UserStore.removeAuditsChangeListener(this._onChange);
},
_onChange: function() {
this.setState(getStateFromStoresForAudits());
},
handleMoreInfo: function(index) {
var newMoreInfo = this.state.moreInfo;
newMoreInfo[index] = true;
this.setState({ moreInfo: newMoreInfo });
},
getInitialState: function() {
var initialState = getStateFromStoresForAudits();
initialState.moreInfo = [];
return initialState;
},
render: function() {
var accessList = [];
var currentHistoryDate = null;
for (var i = 0; i < this.state.audits.length; i++) {
var currentAudit = this.state.audits[i];
var newHistoryDate = new Date(currentAudit.create_at);
var newDate = null;
if (!currentHistoryDate || currentHistoryDate.toLocaleDateString() !== newHistoryDate.toLocaleDateString()) {
currentHistoryDate = newHistoryDate;
newDate = (<div> {currentHistoryDate.toDateString()} </div>);
}
accessList[i] = (
<div className="access-history__table">
<div className="access__date">{newDate}</div>
<div className="access__report">
<div className="report__time">{newHistoryDate.toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit'})}</div>
<div className="report__info">
<div>{"IP: " + currentAudit.ip_address}</div>
{ this.state.moreInfo[i] ?
<div>
<div>{"Session ID: " + currentAudit.session_id}</div>
<div>{"URL: " + currentAudit.action.replace("/api/v1", "")}</div>
</div>
:
<a href="#" onClick={this.handleMoreInfo.bind(this, i)}>More info</a>
}
</div>
{i < this.state.audits.length - 1 ?
<div className="divider-light"/>
:
null
}
</div>
</div>
);
}
return (
<div>
<div className="modal fade" ref="modal" id="access-history" tabIndex="-1" role="dialog" aria-hidden="true">
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 className="modal-title" id="myModalLabel">Access History</h4>
</div>
<div ref="modalBody" className="modal-body">
<form role="form">
{ accessList }
</form>
</div>
</div>
</div>
</div>
</div>
);
}
});

116
web/react/components/activity_log_modal.jsx Обычный файл
Просмотреть файл

@@ -0,0 +1,116 @@
// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved.
// See License.txt for license information.
var UserStore = require('../stores/user_store.jsx');
var Client = require('../utils/client.jsx');
var AsyncClient = require('../utils/async_client.jsx');
function getStateFromStoresForSessions() {
return {
sessions: UserStore.getSessions(),
server_error: null,
client_error: null
};
}
module.exports = React.createClass({
submitRevoke: function(altId) {
var self = this;
Client.revokeSession(altId,
function(data) {
AsyncClient.getSessions();
}.bind(this),
function(err) {
state = getStateFromStoresForSessions();
state.server_error = err;
this.setState(state);
}.bind(this)
);
},
componentDidMount: function() {
UserStore.addSessionsChangeListener(this._onChange);
AsyncClient.getSessions();
var self = this;
$(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) {
self.setState({ moreInfo: [] });
});
},
componentWillUnmount: function() {
UserStore.removeSessionsChangeListener(this._onChange);
},
_onChange: function() {
this.setState(getStateFromStoresForSessions());
},
handleMoreInfo: function(index) {
var newMoreInfo = this.state.moreInfo;
newMoreInfo[index] = true;
this.setState({ moreInfo: newMoreInfo });
},
getInitialState: function() {
var initialState = getStateFromStoresForSessions();
initialState.moreInfo = [];
return initialState;
},
render: function() {
var activityList = [];
var server_error = this.state.server_error ? this.state.server_error : null;
for (var i = 0; i < this.state.sessions.length; i++) {
var currentSession = this.state.sessions[i];
var lastAccessTime = new Date(currentSession.last_activity_at);
var firstAccessTime = new Date(currentSession.create_at);
var devicePicture = "";
if (currentSession.props.platform === "Windows") {
devicePicture = "fa fa-windows";
}
else if (currentSession.props.platform === "Macintosh" || currentSession.props.platform === "iPhone") {
devicePicture = "fa fa-apple";
}
activityList[i] = (
<div className="activity-log__table">
<div className="activity-log__report">
<div className="report__platform"><i className={devicePicture} />{currentSession.props.platform}</div>
<div className="report__info">
<div>{"Last activity: " + lastAccessTime.toDateString() + ", " + lastAccessTime.toLocaleTimeString()}</div>
{ this.state.moreInfo[i] ?
<div>
<div>{"First time active: " + firstAccessTime.toDateString() + ", " + lastAccessTime.toLocaleTimeString()}</div>
<div>{"OS: " + currentSession.props.os}</div>
<div>{"Browser: " + currentSession.props.browser}</div>
<div>{"Session ID: " + currentSession.alt_id}</div>
</div>
:
<a href="#" onClick={this.handleMoreInfo.bind(this, i)}>More info</a>
}
</div>
</div>
<div className="activity-log__action"><button onClick={this.submitRevoke.bind(this, currentSession.alt_id)} className="btn btn-primary">Logout</button></div>
</div>
);
}
return (
<div>
<div className="modal fade" ref="modal" id="activity-log" tabIndex="-1" role="dialog" aria-hidden="true">
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 className="modal-title" id="myModalLabel">Active Devices</h4>
</div>
<div ref="modalBody" className="modal-body">
<form role="form">
{ activityList }
</form>
{ server_error }
</div>
</div>
</div>
</div>
</div>
);
}
});

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

@@ -5,6 +5,7 @@ var client = require('../utils/client.jsx');
var AsyncClient =require('../utils/async_client.jsx'); var AsyncClient =require('../utils/async_client.jsx');
var SocketStore = require('../stores/socket_store.jsx'); var SocketStore = require('../stores/socket_store.jsx');
var ChannelStore = require('../stores/channel_store.jsx'); var ChannelStore = require('../stores/channel_store.jsx');
var PostStore = require('../stores/post_store.jsx');
var Textbox = require('./textbox.jsx'); var Textbox = require('./textbox.jsx');
var MsgTyping = require('./msg_typing.jsx'); var MsgTyping = require('./msg_typing.jsx');
var FileUpload = require('./file_upload.jsx'); var FileUpload = require('./file_upload.jsx');
@@ -43,6 +44,7 @@ module.exports = React.createClass({
client.createPost(post, ChannelStore.getCurrent(), client.createPost(post, ChannelStore.getCurrent(),
function(data) { function(data) {
PostStore.storeCommentDraft(this.props.rootId, null);
this.setState({ messageText: '', submitting: false, post_error: null, server_error: null }); this.setState({ messageText: '', submitting: false, post_error: null, server_error: null });
this.clearPreviews(); this.clearPreviews();
AsyncClient.getPosts(true, this.props.channelId); AsyncClient.getPosts(true, this.props.channelId);
@@ -82,16 +84,33 @@ module.exports = React.createClass({
} }
}, },
handleUserInput: function(messageText) { handleUserInput: function(messageText) {
var draft = PostStore.getCommentDraft(this.props.rootId);
if (!draft) {
draft = { previews: [], uploadsInProgress: 0};
}
draft.message = messageText;
PostStore.storeCommentDraft(this.props.rootId, draft);
$(".post-right__scroll").scrollTop($(".post-right__scroll")[0].scrollHeight); $(".post-right__scroll").scrollTop($(".post-right__scroll")[0].scrollHeight);
$(".post-right__scroll").perfectScrollbar('update'); $(".post-right__scroll").perfectScrollbar('update');
this.setState({messageText: messageText}); this.setState({messageText: messageText});
}, },
handleFileUpload: function(newPreviews) { handleFileUpload: function(newPreviews) {
var draft = PostStore.getCommentDraft(this.props.rootId);
if (!draft) {
draft = { message: '', uploadsInProgress: 0, previews: []}
}
$(".post-right__scroll").scrollTop($(".post-right__scroll")[0].scrollHeight); $(".post-right__scroll").scrollTop($(".post-right__scroll")[0].scrollHeight);
$(".post-right__scroll").perfectScrollbar('update'); $(".post-right__scroll").perfectScrollbar('update');
var oldPreviews = this.state.previews; var previews = this.state.previews.concat(newPreviews);
var num = this.state.uploadsInProgress; var num = this.state.uploadsInProgress;
this.setState({previews: oldPreviews.concat(newPreviews), uploadsInProgress:num-1});
draft.previews = previews;
draft.uploadsInProgress = num-1;
PostStore.storeCommentDraft(this.props.rootId, draft);
this.setState({previews: previews, uploadsInProgress: num-1});
}, },
handleUploadError: function(err) { handleUploadError: function(err) {
this.setState({ server_error: err }); this.setState({ server_error: err });
@@ -107,10 +126,43 @@ module.exports = React.createClass({
break; break;
} }
} }
var draft = PostStore.getCommentDraft();
if (!draft) {
draft = { message: '', uploadsInProgress: 0};
}
draft.previews = previews;
PostStore.storeCommentDraft(draft);
this.setState({previews: previews}); this.setState({previews: previews});
}, },
getInitialState: function() { getInitialState: function() {
return { messageText: '', uploadsInProgress: 0, previews: [], submitting: false }; PostStore.clearCommentDraftUploads();
var draft = PostStore.getCommentDraft(this.props.rootId);
messageText = '';
uploadsInProgress = 0;
previews = [];
if (draft) {
messageText = draft.message;
uploadsInProgress = draft.uploadsInProgress;
previews = draft.previews
}
return { messageText: messageText, uploadsInProgress: uploadsInProgress, previews: previews, submitting: false };
},
componentWillReceiveProps: function(newProps) {
if(newProps.rootId !== this.props.rootId) {
var draft = PostStore.getCommentDraft(newProps.rootId);
messageText = '';
uploadsInProgress = 0;
previews = [];
if (draft) {
messageText = draft.message;
uploadsInProgress = draft.uploadsInProgress;
previews = draft.previews
}
this.setState({ messageText: messageText, uploadsInProgress: uploadsInProgress, previews: previews });
}
}, },
setUploads: function(val) { setUploads: function(val) {
var oldInProgress = this.state.uploadsInProgress var oldInProgress = this.state.uploadsInProgress
@@ -126,6 +178,13 @@ module.exports = React.createClass({
var numToUpload = newInProgress - oldInProgress; var numToUpload = newInProgress - oldInProgress;
if (numToUpload <= 0) return 0; if (numToUpload <= 0) return 0;
var draft = PostStore.getCommentDraft(this.props.rootId);
if (!draft) {
draft = { message: '', previews: []};
}
draft.uploadsInProgress = newInProgress;
PostStore.storeCommentDraft(this.props.rootId, draft);
this.setState({uploadsInProgress: newInProgress}); this.setState({uploadsInProgress: newInProgress});
return numToUpload; return numToUpload;

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

@@ -31,6 +31,11 @@ module.exports = React.createClass({
post.message = this.state.messageText; post.message = this.state.messageText;
// if this is a reply, trim off any carets from the beginning of a message
if (this.state.rootId && post.message.startsWith("^")) {
post.message = post.message.replace(/^\^+\s*/g, "");
}
if (post.message.trim().length === 0 && this.state.previews.length === 0) { if (post.message.trim().length === 0 && this.state.previews.length === 0) {
return; return;
} }
@@ -50,7 +55,7 @@ module.exports = React.createClass({
post.message, post.message,
false, false,
function(data) { function(data) {
PostStore.storeDraft(data.channel_id, user_id, null); PostStore.storeDraft(data.channel_id, null);
this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null, limit_error: null }); this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null, limit_error: null });
if (data.goto_location.length > 0) { if (data.goto_location.length > 0) {
@@ -68,9 +73,12 @@ module.exports = React.createClass({
post.channel_id = this.state.channel_id; post.channel_id = this.state.channel_id;
post.filenames = this.state.previews; post.filenames = this.state.previews;
post.root_id = this.state.rootId;
post.parent_id = this.state.parentId;
client.createPost(post, ChannelStore.getCurrent(), client.createPost(post, ChannelStore.getCurrent(),
function(data) { function(data) {
PostStore.storeDraft(data.channel_id, data.user_id, null); PostStore.storeDraft(data.channel_id, null);
this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null, limit_error: null }); this.setState({ messageText: '', submitting: false, post_error: null, previews: [], server_error: null, limit_error: null });
this.resizePostHolder(); this.resizePostHolder();
AsyncClient.getPosts(true); AsyncClient.getPosts(true);
@@ -84,7 +92,13 @@ module.exports = React.createClass({
}.bind(this), }.bind(this),
function(err) { function(err) {
var state = {} var state = {}
state.server_error = err.message;
if (err.message === "Invalid RootId parameter") {
if ($('#post_deleted').length > 0) $('#post_deleted').modal('show');
} else {
state.server_error = err.message;
}
state.submitting = false; state.submitting = false;
this.setState(state); this.setState(state);
}.bind(this) }.bind(this)
@@ -92,6 +106,17 @@ module.exports = React.createClass({
} }
$(".post-list-holder-by-time").perfectScrollbar('update'); $(".post-list-holder-by-time").perfectScrollbar('update');
if (this.state.rootId || this.state.parentId) {
this.setState({rootId: "", parentId: "", caretCount: 0});
// clear the active thread since we've now sent our message
AppDispatcher.handleViewAction({
type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED,
root_id: "",
parent_id: ""
});
}
}, },
componentDidUpdate: function() { componentDidUpdate: function() {
this.resizePostHolder(); this.resizePostHolder();
@@ -112,6 +137,63 @@ module.exports = React.createClass({
handleUserInput: function(messageText) { handleUserInput: function(messageText) {
this.resizePostHolder(); this.resizePostHolder();
this.setState({messageText: messageText}); this.setState({messageText: messageText});
// look to see if the message begins with any carets to indicate that it's a reply
var replyMatch = messageText.match(/^\^+/g);
if (replyMatch) {
// the number of carets indicates how many message threads back we're replying to
var caretCount = replyMatch[0].length;
// note that if someone else replies to this thread while a user is typing a reply, the message to which they're replying
// won't change unless they change the number of carets. this is probably the desired behaviour since we don't want the
// active message thread to change without the user noticing
if (caretCount != this.state.caretCount) {
this.setState({caretCount: caretCount});
var posts = PostStore.getCurrentPosts();
var rootId = "";
// find the nth most recent post that isn't a comment on another (ie it has no parent) where n is caretCount
for (var i = 0; i < posts.order.length; i++) {
var postId = posts.order[i];
if (posts.posts[postId].parent_id === "") {
caretCount -= 1;
if (caretCount < 1) {
rootId = postId;
break;
}
}
}
// only dispatch an event if something changed
if (rootId != this.state.rootId) {
// set the parent id to match the root id so that we're replying to the first post in the thread
var parentId = rootId;
// alert the post list so that it can display the active thread
AppDispatcher.handleViewAction({
type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED,
root_id: rootId,
parent_id: parentId
});
}
}
} else {
if (this.state.caretCount > 0) {
this.setState({caretCount: 0});
// clear the active thread since there no longer is one
AppDispatcher.handleViewAction({
type: ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED,
root_id: "",
parent_id: ""
});
}
}
var draft = PostStore.getCurrentDraft(); var draft = PostStore.getCurrentDraft();
if (!draft) { if (!draft) {
draft = {} draft = {}
@@ -127,7 +209,7 @@ module.exports = React.createClass({
$(window).trigger('resize'); $(window).trigger('resize');
}, },
handleFileUpload: function(newPreviews, channel_id) { handleFileUpload: function(newPreviews, channel_id) {
var draft = PostStore.getDraft(channel_id, UserStore.getCurrentId()); var draft = PostStore.getDraft(channel_id);
if (!draft) { if (!draft) {
draft = {} draft = {}
draft['message'] = ''; draft['message'] = '';
@@ -148,7 +230,7 @@ module.exports = React.createClass({
} else { } else {
draft['previews'] = draft['previews'].concat(newPreviews); draft['previews'] = draft['previews'].concat(newPreviews);
draft['uploadsInProgress'] = draft['uploadsInProgress'] > 0 ? draft['uploadsInProgress'] - 1 : 0; draft['uploadsInProgress'] = draft['uploadsInProgress'] > 0 ? draft['uploadsInProgress'] - 1 : 0;
PostStore.storeDraft(channel_id, UserStore.getCurrentId(), draft); PostStore.storeDraft(channel_id, draft);
} }
}, },
handleUploadError: function(err) { handleUploadError: function(err) {
@@ -174,10 +256,12 @@ module.exports = React.createClass({
}, },
componentDidMount: function() { componentDidMount: function() {
ChannelStore.addChangeListener(this._onChange); ChannelStore.addChangeListener(this._onChange);
PostStore.addActiveThreadChangedListener(this._onActiveThreadChanged);
this.resizePostHolder(); this.resizePostHolder();
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
ChannelStore.removeChangeListener(this._onChange); ChannelStore.removeChangeListener(this._onChange);
PostStore.removeActiveThreadChangedListener(this._onActiveThreadChanged);
}, },
_onChange: function() { _onChange: function() {
var channel_id = ChannelStore.getCurrentId(); var channel_id = ChannelStore.getCurrentId();
@@ -194,6 +278,11 @@ module.exports = React.createClass({
this.setState({ channel_id: channel_id, messageText: messageText, initialText: messageText, submitting: false, post_error: null, previews: previews, uploadsInProgress: uploadsInProgress }); this.setState({ channel_id: channel_id, messageText: messageText, initialText: messageText, submitting: false, post_error: null, previews: previews, uploadsInProgress: uploadsInProgress });
} }
}, },
_onActiveThreadChanged: function(rootId, parentId) {
// note that we register for our own events and set the state from there so we don't need to manually set
// our state and dispatch an event each time the active thread changes
this.setState({"rootId": rootId, "parentId": parentId});
},
getInitialState: function() { getInitialState: function() {
PostStore.clearDraftUploads(); PostStore.clearDraftUploads();
@@ -204,7 +293,7 @@ module.exports = React.createClass({
previews = draft['previews']; previews = draft['previews'];
messageText = draft['message']; messageText = draft['message'];
} }
return { channel_id: ChannelStore.getCurrentId(), messageText: messageText, uploadsInProgress: 0, previews: previews, submitting: false, initialText: messageText }; return { channel_id: ChannelStore.getCurrentId(), messageText: messageText, uploadsInProgress: 0, previews: previews, submitting: false, initialText: messageText, caretCount: 0 };
}, },
setUploads: function(val) { setUploads: function(val) {
var oldInProgress = this.state.uploadsInProgress var oldInProgress = this.state.uploadsInProgress

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

@@ -6,17 +6,24 @@ var AsyncClient = require('../utils/async_client.jsx');
module.exports = React.createClass({ module.exports = React.createClass({
handleEdit: function(e) { handleEdit: function(e) {
var data = {} var data = {};
data["channel_id"] = this.state.channel_id; data["channel_id"] = this.state.channel_id;
if (data["channel_id"].length !== 26) return; if (data["channel_id"].length !== 26) return;
data["channel_description"] = this.state.description.trim(); data["channel_description"] = this.state.description.trim();
Client.updateChannelDesc(data, Client.updateChannelDesc(data,
function(data) { function(data) {
this.setState({ server_error: "" });
AsyncClient.getChannels(true); AsyncClient.getChannels(true);
$(this.refs.modal.getDOMNode()).modal('hide');
}.bind(this), }.bind(this),
function(err) { function(err) {
AsyncClient.dispatchError(err, "updateChannelDesc"); if (err.message === "Invalid channel_description parameter") {
this.setState({ server_error: "This description is too long, please enter a shorter one" });
}
else {
this.setState({ server_error: err.message });
}
}.bind(this) }.bind(this)
); );
}, },
@@ -27,13 +34,15 @@ module.exports = React.createClass({
var self = this; var self = this;
$(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) { $(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
var button = e.relatedTarget; var button = e.relatedTarget;
self.setState({ description: $(button).attr('data-desc'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid') }); self.setState({ description: $(button).attr('data-desc'), title: $(button).attr('data-title'), channel_id: $(button).attr('data-channelid'), server_error: "" });
}); });
}, },
getInitialState: function() { getInitialState: function() {
return { description: "", title: "", channel_id: "" }; return { description: "", title: "", channel_id: "" };
}, },
render: function() { render: function() {
var server_error = this.state.server_error ? <div className='form-group has-error'><br/><label className='control-label'>{ this.state.server_error }</label></div> : null;
return ( return (
<div className="modal fade" ref="modal" id="edit_channel" role="dialog" aria-hidden="true"> <div className="modal fade" ref="modal" id="edit_channel" role="dialog" aria-hidden="true">
<div className="modal-dialog"> <div className="modal-dialog">
@@ -44,10 +53,11 @@ module.exports = React.createClass({
</div> </div>
<div className="modal-body"> <div className="modal-body">
<textarea className="form-control no-resize" rows="6" ref="channelDesc" maxLength="1024" value={this.state.description} onChange={this.handleUserInput}></textarea> <textarea className="form-control no-resize" rows="6" ref="channelDesc" maxLength="1024" value={this.state.description} onChange={this.handleUserInput}></textarea>
{ server_error }
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="btn btn-default" data-dismiss="modal">Close</button> <button type="button" className="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" className="btn btn-primary" data-dismiss="modal" onClick={this.handleEdit}>Save</button> <button type="button" className="btn btn-primary" onClick={this.handleEdit}>Save</button>
</div> </div>
</div> </div>
</div> </div>

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

@@ -16,20 +16,26 @@ module.exports = React.createClass({
var previews = []; var previews = [];
this.props.files.forEach(function(filename) { this.props.files.forEach(function(filename) {
var originalFilename = filename;
var filenameSplit = filename.split('.'); var filenameSplit = filename.split('.');
var ext = filenameSplit[filenameSplit.length-1]; var ext = filenameSplit[filenameSplit.length-1];
var type = utils.getFileType(ext); var type = utils.getFileType(ext);
// This is a temporary patch to fix issue with old files using absolute paths
if (filename.indexOf("/api/v1/files/get") != -1) {
filename = filename.split("/api/v1/files/get")[1];
}
filename = window.location.origin + "/api/v1/files/get" + filename;
if (type === "image") { if (type === "image") {
previews.push( previews.push(
<div key={filename} className="preview-div" data-filename={filename}> <div key={filename} className="preview-div" data-filename={originalFilename}>
<img className="preview-img" src={filename}/> <img className="preview-img" src={filename}/>
<a className="remove-preview" onClick={this.handleRemove}><i className="glyphicon glyphicon-remove"/></a> <a className="remove-preview" onClick={this.handleRemove}><i className="glyphicon glyphicon-remove"/></a>
</div> </div>
); );
} else { } else {
previews.push( previews.push(
<div key={filename} className="preview-div custom-file" data-filename={filename}> <div key={filename} className="preview-div custom-file" data-filename={originalFilename}>
<div className={"file-icon "+utils.getIconClassName(type)}/> <div className={"file-icon "+utils.getIconClassName(type)}/>
<a className="remove-preview" onClick={this.handleRemove}><i className="glyphicon glyphicon-remove"/></a> <a className="remove-preview" onClick={this.handleRemove}><i className="glyphicon glyphicon-remove"/></a>
</div> </div>

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

@@ -28,7 +28,7 @@ function getCountsStateFromStores() {
} else { } else {
if (channelMember.mention_count > 0) { if (channelMember.mention_count > 0) {
count += channelMember.mention_count; count += channelMember.mention_count;
} else if (channel.total_msg_count - channelMember.msg_count > 0) { } else if (channelMember.notify_level !== "quiet" && channel.total_msg_count - channelMember.msg_count > 0) {
count += 1; count += 1;
} }
} }

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

@@ -83,7 +83,7 @@ module.exports = React.createClass({
<img className="post-profile-img" src={"/api/v1/users/" + post.user_id + "/image?time=" + timestamp} height="36" width="36" /> <img className="post-profile-img" src={"/api/v1/users/" + post.user_id + "/image?time=" + timestamp} height="36" width="36" />
</div> </div>
: null } : null }
<div className="post__content"> <div className={"post__content" + (this.props.isActiveThread ? " active-thread__content" : "")}>
<PostHeader ref="header" post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} isLastComment={this.props.isLastComment} /> <PostHeader ref="header" post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} isLastComment={this.props.isLastComment} />
<PostBody post={post} sameRoot={this.props.sameRoot} parentPost={parentPost} posts={posts} handleCommentClick={this.handleCommentClick} /> <PostBody post={post} sameRoot={this.props.sameRoot} parentPost={parentPost} posts={posts} handleCommentClick={this.handleCommentClick} />
<PostInfo ref="info" post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} allowReply="true" /> <PostInfo ref="info" post={post} sameRoot={this.props.sameRoot} commentCount={commentCount} handleCommentClick={this.handleCommentClick} allowReply="true" />

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

@@ -28,6 +28,12 @@ module.exports = React.createClass({
var type = utils.getFileType(fileInfo.ext); var type = utils.getFileType(fileInfo.ext);
// This is a temporary patch to fix issue with old files using absolute paths
if (fileInfo.path.indexOf("/api/v1/files/get") != -1) {
fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1];
}
fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path;
if (type === "image") { if (type === "image") {
$('<img/>').attr('src', fileInfo.path+'_thumb.jpg').load(function(path, name){ return function() { $('<img/>').attr('src', fileInfo.path+'_thumb.jpg').load(function(path, name){ return function() {
$(this).remove(); $(this).remove();
@@ -102,6 +108,12 @@ module.exports = React.createClass({
var type = utils.getFileType(fileInfo.ext); var type = utils.getFileType(fileInfo.ext);
// This is a temporary patch to fix issue with old files using absolute paths
if (fileInfo.path.indexOf("/api/v1/files/get") != -1) {
fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1];
}
fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path;
if (type === "image") { if (type === "image") {
if (i < Constants.MAX_DISPLAY_FILES) { if (i < Constants.MAX_DISPLAY_FILES) {
postFiles.push( postFiles.push(

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

@@ -22,7 +22,8 @@ function getStateFromStores() {
return { return {
post_list: PostStore.getCurrentPosts(), post_list: PostStore.getCurrentPosts(),
channel: channel channel: channel,
activeThreadRootId: ""
}; };
} }
@@ -51,6 +52,7 @@ module.exports = React.createClass({
ChannelStore.addChangeListener(this._onChange); ChannelStore.addChangeListener(this._onChange);
UserStore.addStatusesChangeListener(this._onTimeChange); UserStore.addStatusesChangeListener(this._onTimeChange);
SocketStore.addChangeListener(this._onSocketChange); SocketStore.addChangeListener(this._onSocketChange);
PostStore.addActiveThreadChangedListener(this._onActiveThreadChanged);
$(".post-list-holder-by-time").perfectScrollbar(); $(".post-list-holder-by-time").perfectScrollbar();
@@ -131,6 +133,7 @@ module.exports = React.createClass({
ChannelStore.removeChangeListener(this._onChange); ChannelStore.removeChangeListener(this._onChange);
UserStore.removeStatusesChangeListener(this._onTimeChange); UserStore.removeStatusesChangeListener(this._onTimeChange);
SocketStore.removeChangeListener(this._onSocketChange); SocketStore.removeChangeListener(this._onSocketChange);
PostStore.removeActiveThreadChangedListener(this._onActiveThreadChanged);
$('body').off('click.userpopover'); $('body').off('click.userpopover');
}, },
resize: function() { resize: function() {
@@ -223,11 +226,15 @@ module.exports = React.createClass({
} }
}, },
_onTimeChange: function() { _onTimeChange: function() {
if (!this.state.post_list) return;
for (var id in this.state.post_list.posts) { for (var id in this.state.post_list.posts) {
if (!this.refs[id]) continue; if (!this.refs[id]) continue;
this.refs[id].forceUpdateInfo(); this.refs[id].forceUpdateInfo();
} }
}, },
_onActiveThreadChanged: function(rootId, parentId) {
this.setState({"activeThreadRootId": rootId});
},
getMorePosts: function(e) { getMorePosts: function(e) {
e.preventDefault(); e.preventDefault();
@@ -347,8 +354,8 @@ module.exports = React.createClass({
if (ChannelStore.isDefault(channel)) { if (ChannelStore.isDefault(channel)) {
more_messages = ( more_messages = (
<div className="channel-intro"> <div className="channel-intro">
<h4 className="channel-intro-title">Welcome</h4> <h4 className="channel-intro__title">Beginning of {ui_name}</h4>
<p> <p className="channel-intro__content">
Welcome to {ui_name}! Welcome to {ui_name}!
<br/><br/> <br/><br/>
{"This is the first channel " + strings.Team + "mates see when they"} {"This is the first channel " + strings.Team + "mates see when they"}
@@ -365,27 +372,27 @@ module.exports = React.createClass({
} else if (channel.name === Constants.OFFTOPIC_CHANNEL) { } else if (channel.name === Constants.OFFTOPIC_CHANNEL) {
more_messages = ( more_messages = (
<div className="channel-intro"> <div className="channel-intro">
<h4 className="channel-intro-title">Welcome</h4> <h4 className="channel-intro__title">Beginning of {ui_name}</h4>
<p> <p className="channel-intro__content">
{"This is the start of " + ui_name + ", a channel for conversations youd prefer out of more focused channels."} {"This is the start of " + ui_name + ", a channel for conversations youd prefer out of more focused channels."}
<br/> <br/>
<a className="intro-links" href="#" style={userStyle} data-toggle="modal" data-target="#edit_channel" data-desc={channel.description} data-title={ui_name} data-channelid={channel.id}><i className="fa fa-pencil"></i>Set a description</a>
</p> </p>
<a className="intro-links" href="#" style={userStyle} data-toggle="modal" data-target="#edit_channel" data-desc={channel.description} data-title={ui_name} data-channelid={channel.id}><i className="fa fa-pencil"></i>Set a description</a>
</div> </div>
); );
} else { } else {
var ui_type = channel.type === 'P' ? "private group" : "channel"; var ui_type = channel.type === 'P' ? "private group" : "channel";
more_messages = ( more_messages = (
<div className="channel-intro"> <div className="channel-intro">
<h4 className="channel-intro-title">Welcome</h4> <h4 className="channel-intro__title">Beginning of {ui_name}</h4>
<p> <p className="channel-intro__content">
{ creator_name != "" ? "This is the start of the " + ui_name + " " + ui_type + ", created by " + creator_name + " on " + utils.displayDate(channel.create_at) + "." { creator_name != "" ? "This is the start of the " + ui_name + " " + ui_type + ", created by " + creator_name + " on " + utils.displayDate(channel.create_at) + "."
: "This is the start of the " + ui_name + " " + ui_type + ", created on "+ utils.displayDate(channel.create_at) + "." } : "This is the start of the " + ui_name + " " + ui_type + ", created on "+ utils.displayDate(channel.create_at) + "." }
{ channel.type === 'P' ? " Only invited members can see this private group." : " Any member can join and read this channel." } { channel.type === 'P' ? " Only invited members can see this private group." : " Any member can join and read this channel." }
<br/> <br/>
<a className="intro-links" href="#" style={userStyle} data-toggle="modal" data-target="#edit_channel" data-desc={channel.description} data-title={channel.display_name} data-channelid={channel.id}><i className="fa fa-pencil"></i>Set a description</a>
<a className="intro-links" href="#" style={userStyle} data-toggle="modal" data-target="#channel_invite"><i className="fa fa-user-plus"></i>Invite others to this {ui_type}</a>
</p> </p>
<a className="intro-links" href="#" style={userStyle} data-toggle="modal" data-target="#edit_channel" data-desc={channel.description} data-title={channel.display_name} data-channelid={channel.id}><i className="fa fa-pencil"></i>Set a description</a>
<a className="intro-links" href="#" style={userStyle} data-toggle="modal" data-target="#channel_invite"><i className="fa fa-user-plus"></i>Invite others to this {ui_type}</a>
</div> </div>
); );
} }
@@ -419,7 +426,14 @@ module.exports = React.createClass({
// it is the last comment if it is last post in the channel or the next post has a different root post // it is the last comment if it is last post in the channel or the next post has a different root post
var isLastComment = utils.isComment(post) && (i === 0 || posts[order[i-1]].root_id != post.root_id); var isLastComment = utils.isComment(post) && (i === 0 || posts[order[i-1]].root_id != post.root_id);
var postCtl = <Post ref={post.id} sameUser={sameUser} sameRoot={sameRoot} post={post} parentPost={parentPost} key={post.id} posts={posts} hideProfilePic={hideProfilePic} isLastComment={isLastComment} />; // check if this is part of the thread that we're currently replying to
var isActiveThread = this.state.activeThreadRootId && (post.id === this.state.activeThreadRootId || post.root_id === this.state.activeThreadRootId);
var postCtl = (
<Post ref={post.id} sameUser={sameUser} sameRoot={sameRoot} post={post} parentPost={parentPost} key={post.id}
posts={posts} hideProfilePic={hideProfilePic} isLastComment={isLastComment} isActiveThread={isActiveThread}
/>
);
currentPostDay = utils.getDateForUnixTicks(post.create_at); currentPostDay = utils.getDateForUnixTicks(post.create_at);
if (currentPostDay.toDateString() != previousPostDay.toDateString()) { if (currentPostDay.toDateString() != previousPostDay.toDateString()) {

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

@@ -91,28 +91,27 @@ RootPost = React.createClass({
var re2 = new RegExp('\\(', 'g'); var re2 = new RegExp('\\(', 'g');
var re3 = new RegExp('\\)', 'g'); var re3 = new RegExp('\\)', 'g');
for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) {
var fileSplit = filenames[i].split('.'); var fileInfo = utils.splitFileLocation(filenames[i]);
if (fileSplit.length < 2) continue; var ftype = utils.getFileType(fileInfo.ext);
var ext = fileSplit[fileSplit.length-1]; // This is a temporary patch to fix issue with old files using absolute paths
fileSplit.splice(fileSplit.length-1,1); if (fileInfo.path.indexOf("/api/v1/files/get") != -1) {
var filePath = fileSplit.join('.'); fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1];
var filename = filePath.split('/')[filePath.split('/').length-1]; }
fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path;
var ftype = utils.getFileType(ext);
if (ftype === "image") { if (ftype === "image") {
var url = filePath.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); var url = fileInfo.path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29');
postFiles.push( postFiles.push(
<div className="post-image__column" key={filePath}> <div className="post-image__column" key={fileInfo.path}>
<a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={filePath} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a> <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={fileInfo.path} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a>
</div> </div>
); );
images.push(filenames[i]); images.push(filenames[i]);
} else { } else {
postFiles.push( postFiles.push(
<div className="post-image__column custom-file" key={filePath}> <div className="post-image__column custom-file" key={fileInfo.path}>
<a href={filePath+"."+ext} download={filename+"."+ext}> <a href={fileInfo.path+"."+ext} download={fileInfo.name+"."+ext}>
<div className={"file-icon "+utils.getIconClassName(ftype)}/> <div className={"file-icon "+utils.getIconClassName(ftype)}/>
</a> </a>
</div> </div>
@@ -201,28 +200,28 @@ CommentPost = React.createClass({
var re2 = new RegExp('\\(', 'g'); var re2 = new RegExp('\\(', 'g');
var re3 = new RegExp('\\)', 'g'); var re3 = new RegExp('\\)', 'g');
for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) { for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) {
var fileSplit = filenames[i].split('.');
if (fileSplit.length < 2) continue;
var ext = fileSplit[fileSplit.length-1]; var fileInfo = utils.splitFileLocation(filenames[i]);
fileSplit.splice(fileSplit.length-1,1) var type = utils.getFileType(fileInfo.ext);
var filePath = fileSplit.join('.');
var filename = filePath.split('/')[filePath.split('/').length-1];
var type = utils.getFileType(ext); // This is a temporary patch to fix issue with old files using absolute paths
if (fileInfo.path.indexOf("/api/v1/files/get") != -1) {
fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1];
}
fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path;
if (type === "image") { if (type === "image") {
var url = filePath.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29'); var url = fileInfo.path.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29');
postFiles.push( postFiles.push(
<div className="post-image__column" key={filename}> <div className="post-image__column" key={fileInfo.path}>
<a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={filename} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a> <a href="#" onClick={this.handleImageClick} data-img-id={images.length.toString()} data-toggle="modal" data-target={"#" + postImageModalId }><div ref={fileInfo.path} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a>
</div> </div>
); );
images.push(filenames[i]); images.push(filenames[i]);
} else { } else {
postFiles.push( postFiles.push(
<div className="post-image__column custom-file" key={filename}> <div className="post-image__column custom-file" key={fileInfo.path}>
<a href={filePath+"."+ext} download={filename+"."+ext}> <a href={fileInfo.path+"."+fileInfo.ext} download={fileInfo.name+"."+fileInfo.ext}>
<div className={"file-icon "+utils.getIconClassName(type)}/> <div className={"file-icon "+utils.getIconClassName(type)}/>
</a> </a>
</div> </div>
@@ -294,6 +293,8 @@ module.exports = React.createClass({
}); });
}, },
componentDidUpdate: function() { componentDidUpdate: function() {
$(".post-right__scroll").scrollTop($(".post-right__scroll")[0].scrollHeight);
$(".post-right__scroll").perfectScrollbar('update');
this.resize(); this.resize();
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@@ -352,6 +353,7 @@ module.exports = React.createClass({
$(".post-right__scroll").css("height", height + "px"); $(".post-right__scroll").css("height", height + "px");
$(".post-right__scroll").scrollTop(100000); $(".post-right__scroll").scrollTop(100000);
$(".post-right__scroll").perfectScrollbar(); $(".post-right__scroll").perfectScrollbar();
$(".post-right__scroll").perfectScrollbar('update');
}, },
render: function() { render: function() {

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

@@ -115,7 +115,11 @@ module.exports = React.createClass({
return ( return (
<div className="team__header theme"> <div className="team__header theme">
<a className="settings_link" href="#" data-toggle="modal" data-target="#user_settings1"> <a className="settings_link" href="#" data-toggle="modal" data-target="#user_settings1">
{ me.last_picture_update ?
<img className="user__picture" src={"/api/v1/users/" + me.id + "/image?time=" + me.update_at} /> <img className="user__picture" src={"/api/v1/users/" + me.id + "/image?time=" + me.update_at} />
:
<div />
}
<div className="header__info"> <div className="header__info">
<div className="user__name">{ '@' + me.username}</div> <div className="user__name">{ '@' + me.username}</div>
<div className="team__name">{ teamDisplayName }</div> <div className="team__name">{ teamDisplayName }</div>

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

@@ -5,6 +5,8 @@ var UserStore = require('../stores/user_store.jsx');
var SettingItemMin = require('./setting_item_min.jsx'); var SettingItemMin = require('./setting_item_min.jsx');
var SettingItemMax = require('./setting_item_max.jsx'); var SettingItemMax = require('./setting_item_max.jsx');
var SettingPicture = require('./setting_picture.jsx'); var SettingPicture = require('./setting_picture.jsx');
var AccessHistoryModal = require('./access_history_modal.jsx');
var ActivityLogModal = require('./activity_log_modal.jsx');
var client = require('../utils/client.jsx'); var client = require('../utils/client.jsx');
var AsyncClient = require('../utils/async_client.jsx'); var AsyncClient = require('../utils/async_client.jsx');
var utils = require('../utils/utils.jsx'); var utils = require('../utils/utils.jsx');
@@ -443,149 +445,6 @@ var NotificationsTab = React.createClass({
} }
}); });
function getStateFromStoresForSessions() {
return {
sessions: UserStore.getSessions(),
server_error: null,
client_error: null
};
}
var SessionsTab = React.createClass({
submitRevoke: function(altId) {
client.revokeSession(altId,
function(data) {
AsyncClient.getSessions();
}.bind(this),
function(err) {
state = this.getStateFromStoresForSessions();
state.server_error = err;
this.setState(state);
}.bind(this)
);
},
componentDidMount: function() {
UserStore.addSessionsChangeListener(this._onChange);
AsyncClient.getSessions();
},
componentWillUnmount: function() {
UserStore.removeSessionsChangeListener(this._onChange);
},
_onChange: function() {
this.setState(getStateFromStoresForSessions());
},
getInitialState: function() {
return getStateFromStoresForSessions();
},
render: function() {
var server_error = this.state.server_error ? this.state.server_error : null;
return (
<div>
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 className="modal-title" ref="title"><i className="modal-back"></i>Sessions</h4>
</div>
<div className="user-settings">
<h3 className="tab-header">Sessions</h3>
<div className="divider-dark first"/>
{ server_error }
<div className="table-responsive" style={{ maxWidth: "560px", maxHeight: "300px" }}>
<table className="table-condensed small">
<thead>
<tr><th>Id</th><th>Platform</th><th>OS</th><th>Browser</th><th>Created</th><th>Last Activity</th><th>Revoke</th></tr>
</thead>
<tbody>
{
this.state.sessions.map(function(value, index) {
return (
<tr key={ "" + index }>
<td style={{ whiteSpace: "nowrap" }}>{ value.alt_id }</td>
<td style={{ whiteSpace: "nowrap" }}>{value.props.platform}</td>
<td style={{ whiteSpace: "nowrap" }}>{value.props.os}</td>
<td style={{ whiteSpace: "nowrap" }}>{value.props.browser}</td>
<td style={{ whiteSpace: "nowrap" }}>{ new Date(value.create_at).toLocaleString() }</td>
<td style={{ whiteSpace: "nowrap" }}>{ new Date(value.last_activity_at).toLocaleString() }</td>
<td><button onClick={this.submitRevoke.bind(this, value.alt_id)} className="pull-right btn btn-primary">Revoke</button></td>
</tr>
);
}, this)
}
</tbody>
</table>
</div>
<div className="divider-dark"/>
</div>
</div>
);
}
});
function getStateFromStoresForAudits() {
return {
audits: UserStore.getAudits()
};
}
var AuditTab = React.createClass({
componentDidMount: function() {
UserStore.addAuditsChangeListener(this._onChange);
AsyncClient.getAudits();
},
componentWillUnmount: function() {
UserStore.removeAuditsChangeListener(this._onChange);
},
_onChange: function() {
this.setState(getStateFromStoresForAudits());
},
getInitialState: function() {
return getStateFromStoresForAudits();
},
render: function() {
return (
<div>
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 className="modal-title" ref="title"><i className="modal-back"></i>Activity Log</h4>
</div>
<div className="user-settings">
<h3 className="tab-header">Activity Log</h3>
<div className="divider-dark first"/>
<div className="table-responsive">
<table className="table-condensed small">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>IP Address</th>
<th>Session</th>
<th>Other Info</th>
</tr>
</thead>
<tbody>
{
this.state.audits.map(function(value, index) {
return (
<tr key={ "" + index }>
<td className="text-nowrap">{ new Date(value.create_at).toLocaleString() }</td>
<td className="text-nowrap">{ value.action.replace("/api/v1", "") }</td>
<td className="text-nowrap">{ value.ip_address }</td>
<td className="text-nowrap">{ value.session_id }</td>
<td className="text-nowrap">{ value.extra_info }</td>
</tr>
);
}, this)
}
</tbody>
</table>
</div>
<div className="divider-dark"/>
</div>
</div>
);
}
});
var SecurityTab = React.createClass({ var SecurityTab = React.createClass({
submitPassword: function(e) { submitPassword: function(e) {
e.preventDefault(); e.preventDefault();
@@ -637,6 +496,12 @@ var SecurityTab = React.createClass({
updateConfirmPassword: function(e) { updateConfirmPassword: function(e) {
this.setState({ confirm_password: e.target.value }); this.setState({ confirm_password: e.target.value });
}, },
handleHistoryOpen: function() {
$("#user_settings1").modal('hide');
},
handleDevicesOpen: function() {
$("#user_settings1").modal('hide');
},
getInitialState: function() { getInitialState: function() {
return { current_password: '', new_password: '', confirm_password: '' }; return { current_password: '', new_password: '', confirm_password: '' };
}, },
@@ -711,6 +576,10 @@ var SecurityTab = React.createClass({
<div className="divider-dark first"/> <div className="divider-dark first"/>
{ passwordSection } { passwordSection }
<div className="divider-dark"/> <div className="divider-dark"/>
<br></br>
<a data-toggle="modal" className="security-links" data-target="#access-history" href="#" onClick={this.handleHistoryOpen}><i className="fa fa-clock-o"></i>View Access History</a>
<b> </b>
<a data-toggle="modal" className="security-links" data-target="#activity-log" href="#" onClick={this.handleDevicesOpen}><i className="fa fa-globe"></i>View and Logout of Active Devices</a>
</div> </div>
</div> </div>
); );
@@ -1225,23 +1094,6 @@ module.exports = React.createClass({
<NotificationsTab user={this.state.user} activeSection={this.props.activeSection} updateSection={this.props.updateSection} /> <NotificationsTab user={this.state.user} activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
</div> </div>
); );
/* Temporarily removing sessions and activity_log tabs
} else if (this.props.activeTab === 'sessions') {
return (
<div>
<SessionsTab activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
</div>
);
} else if (this.props.activeTab === 'activity_log') {
return (
<div>
<AuditTab activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
</div>
);
*/
} else if (this.props.activeTab === 'appearance') { } else if (this.props.activeTab === 'appearance') {
return ( return (
<div> <div>

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

@@ -30,8 +30,6 @@ module.exports = React.createClass({
tabs.push({name: "security", ui_name: "Security", icon: "glyphicon glyphicon-lock"}); tabs.push({name: "security", ui_name: "Security", icon: "glyphicon glyphicon-lock"});
tabs.push({name: "notifications", ui_name: "Notifications", icon: "glyphicon glyphicon-exclamation-sign"}); tabs.push({name: "notifications", ui_name: "Notifications", icon: "glyphicon glyphicon-exclamation-sign"});
tabs.push({name: "appearance", ui_name: "Appearance", icon: "glyphicon glyphicon-wrench"}); tabs.push({name: "appearance", ui_name: "Appearance", icon: "glyphicon glyphicon-wrench"});
//tabs.push({name: "sessions", ui_name: "Sessions", icon: "glyphicon glyphicon-globe"});
//tabs.push({name: "activity_log", ui_name: "Activity Log", icon: "glyphicon glyphicon-time"});
return ( return (
<div className="modal fade" ref="modal" id="user_settings1" role="dialog" aria-hidden="true"> <div className="modal fade" ref="modal" id="user_settings1" role="dialog" aria-hidden="true">

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

@@ -36,6 +36,11 @@ module.exports = React.createClass({
src = this.props.filenames[id]; src = this.props.filenames[id];
} else { } else {
var fileInfo = utils.splitFileLocation(this.props.filenames[id]); var fileInfo = utils.splitFileLocation(this.props.filenames[id]);
// This is a temporary patch to fix issue with old files using absolute paths
if (fileInfo.path.indexOf("/api/v1/files/get") !== -1) {
fileInfo.path = fileInfo.path.split("/api/v1/files/get")[1];
}
fileInfo.path = window.location.origin + "/api/v1/files/get" + fileInfo.path;
src = fileInfo['path'] + '_preview.jpg'; src = fileInfo['path'] + '_preview.jpg';
} }
@@ -139,18 +144,30 @@ module.exports = React.createClass({
if (this.props.imgCount > 0) { if (this.props.imgCount > 0) {
preview_filename = this.props.filenames[this.state.imgId]; preview_filename = this.props.filenames[this.state.imgId];
} else { } else {
// This is a temporary patch to fix issue with old files using absolute paths
if (info.path.indexOf("/api/v1/files/get") !== -1) {
info.path = info.path.split("/api/v1/files/get")[1];
}
info.path = window.location.origin + "/api/v1/files/get" + info.path;
preview_filename = info['path'] + '_preview.jpg'; preview_filename = info['path'] + '_preview.jpg';
} }
var imgClass = "hidden"; var imgClass = "hidden";
if (this.state.loaded[id] && this.state.imgId == id) imgClass = ""; if (this.state.loaded[id] && this.state.imgId == id) imgClass = "";
img[info['path']] = <a key={info['path']} className={imgClass} href={this.props.filenames[id]} target="_blank"><img ref="image" src={preview_filename}/></a>; img[info['path']] = <a key={info['path']} className={imgClass} href={info.path+"."+info.ext} target="_blank"><img ref="image" src={preview_filename}/></a>;
} }
} }
var imgFragment = React.addons.createFragment(img); var imgFragment = React.addons.createFragment(img);
// This is a temporary patch to fix issue with old files using absolute paths
var download_link = this.props.filenames[this.state.imgId];
if (download_link.indexOf("/api/v1/files/get") !== -1) {
download_link = download_link.split("/api/v1/files/get")[1];
}
download_link = window.location.origin + "/api/v1/files/get" + download_link;
return ( return (
<div className="modal fade image_modal" ref="modal" id={this.props.modalId} tabIndex="-1" role="dialog" aria-hidden="true"> <div className="modal fade image_modal" ref="modal" id={this.props.modalId} tabIndex="-1" role="dialog" aria-hidden="true">
<div className="modal-dialog modal-image"> <div className="modal-dialog modal-image">
@@ -168,7 +185,7 @@ module.exports = React.createClass({
<span className="text"> | </span> <span className="text"> | </span>
</div> </div>
: "" } : "" }
<a href={this.props.filenames[id]} download={decodeURIComponent(name)} className="text">Download</a> <a href={download_link} download={decodeURIComponent(name)} className="text">Download</a>
</div> </div>
</div> </div>
{loading} {loading}

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

@@ -32,6 +32,8 @@ var ErrorBar = require('../components/error_bar.jsx')
var ChannelLoader = require('../components/channel_loader.jsx'); var ChannelLoader = require('../components/channel_loader.jsx');
var MentionList = require('../components/mention_list.jsx'); var MentionList = require('../components/mention_list.jsx');
var ChannelInfoModal = require('../components/channel_info_modal.jsx'); var ChannelInfoModal = require('../components/channel_info_modal.jsx');
var AccessHistoryModal = require('../components/access_history_modal.jsx');
var ActivityLogModal = require('../components/activity_log_modal.jsx');
var Constants = require('../utils/constants.jsx'); var Constants = require('../utils/constants.jsx');
@@ -205,4 +207,14 @@ global.window.setup_channel_page = function(team_name, team_type, team_id, chann
document.getElementById('edit_mention_tab') document.getElementById('edit_mention_tab')
); );
React.render(
<AccessHistoryModal />,
document.getElementById('access_history_modal')
);
React.render(
<ActivityLogModal />,
document.getElementById('activity_log_modal')
);
}; };

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

@@ -18,6 +18,7 @@ var SEARCH_TERM_CHANGE_EVENT = 'search_term_change';
var SELECTED_POST_CHANGE_EVENT = 'selected_post_change'; var SELECTED_POST_CHANGE_EVENT = 'selected_post_change';
var MENTION_DATA_CHANGE_EVENT = 'mention_data_change'; var MENTION_DATA_CHANGE_EVENT = 'mention_data_change';
var ADD_MENTION_EVENT = 'add_mention'; var ADD_MENTION_EVENT = 'add_mention';
var ACTIVE_THREAD_CHANGED_EVENT = 'active_thread_changed';
var PostStore = assign({}, EventEmitter.prototype, { var PostStore = assign({}, EventEmitter.prototype, {
@@ -93,6 +94,18 @@ var PostStore = assign({}, EventEmitter.prototype, {
this.removeListener(ADD_MENTION_EVENT, callback); this.removeListener(ADD_MENTION_EVENT, callback);
}, },
emitActiveThreadChanged: function(rootId, parentId) {
this.emit(ACTIVE_THREAD_CHANGED_EVENT, rootId, parentId);
},
addActiveThreadChangedListener: function(callback) {
this.on(ACTIVE_THREAD_CHANGED_EVENT, callback);
},
removeActiveThreadChangedListener: function(callback) {
this.removeListener(ACTIVE_THREAD_CHANGED_EVENT, callback);
},
getCurrentPosts: function() { getCurrentPosts: function() {
var currentId = ChannelStore.getCurrentId(); var currentId = ChannelStore.getCurrentId();
@@ -136,19 +149,23 @@ var PostStore = assign({}, EventEmitter.prototype, {
}, },
storeCurrentDraft: function(draft) { storeCurrentDraft: function(draft) {
var channel_id = ChannelStore.getCurrentId(); var channel_id = ChannelStore.getCurrentId();
var user_id = UserStore.getCurrentId(); BrowserStore.setItem("draft_" + channel_id, draft);
BrowserStore.setItem("draft_" + channel_id + "_" + user_id, draft);
}, },
getCurrentDraft: function() { getCurrentDraft: function() {
var channel_id = ChannelStore.getCurrentId(); var channel_id = ChannelStore.getCurrentId();
var user_id = UserStore.getCurrentId(); return BrowserStore.getItem("draft_" + channel_id);
return BrowserStore.getItem("draft_" + channel_id + "_" + user_id);
}, },
storeDraft: function(channel_id, user_id, draft) { storeDraft: function(channel_id, draft) {
BrowserStore.setItem("draft_" + channel_id + "_" + user_id, draft); BrowserStore.setItem("draft_" + channel_id, draft);
}, },
getDraft: function(channel_id, user_id) { getDraft: function(channel_id) {
return BrowserStore.getItem("draft_" + channel_id + "_" + user_id); return BrowserStore.getItem("draft_" + channel_id);
},
storeCommentDraft: function(parent_post_id, draft) {
BrowserStore.setItem("comment_draft_" + parent_post_id, draft);
},
getCommentDraft: function(parent_post_id) {
return BrowserStore.getItem("comment_draft_" + parent_post_id);
}, },
clearDraftUploads: function() { clearDraftUploads: function() {
BrowserStore.actionOnItemsWithPrefix("draft_", function (key, value) { BrowserStore.actionOnItemsWithPrefix("draft_", function (key, value) {
@@ -157,6 +174,14 @@ var PostStore = assign({}, EventEmitter.prototype, {
BrowserStore.setItem(key, value); BrowserStore.setItem(key, value);
} }
}); });
},
clearCommentDraftUploads: function() {
BrowserStore.actionOnItemsWithPrefix("comment_draft_", function (key, value) {
if (value) {
value.uploadsInProgress = 0;
BrowserStore.setItem(key, value);
}
});
} }
}); });
@@ -186,6 +211,9 @@ PostStore.dispatchToken = AppDispatcher.register(function(payload) {
case ActionTypes.RECIEVED_ADD_MENTION: case ActionTypes.RECIEVED_ADD_MENTION:
PostStore.emitAddMention(action.id, action.username); PostStore.emitAddMention(action.id, action.username);
break; break;
case ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED:
PostStore.emitActiveThreadChanged(action.root_id, action.parent_id);
break;
default: default:
} }

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

@@ -18,6 +18,7 @@ module.exports = {
RECIEVED_POST_SELECTED: null, RECIEVED_POST_SELECTED: null,
RECIEVED_MENTION_DATA: null, RECIEVED_MENTION_DATA: null,
RECIEVED_ADD_MENTION: null, RECIEVED_ADD_MENTION: null,
RECEIVED_ACTIVE_THREAD_CHANGED: null,
RECIEVED_PROFILES: null, RECIEVED_PROFILES: null,
RECIEVED_ME: null, RECIEVED_ME: null,

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

@@ -0,0 +1,29 @@
.access-history__table {
display: table;
width: 100%;
padding-top: 15px;
line-height: 1.6;
&:first-child {
padding: 0;
}
> div {
display: table-cell;
vertical-align: top;
}
.access__date {
font-weight: 600;
font-size: 16px;
width: 190px;
}
.access__report {
border-bottom: 1px solid #ddd;
padding-bottom: 15px;
}
.report__time {
font-weight: 600;
font-size: 16px;
}
.report__info {
color: #999;
}
}

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

@@ -0,0 +1,31 @@
.activity-log__table {
display: table;
width: 100%;
line-height: 1.8;
border-top: 1px solid #DDD;
padding: 15px 0;
&:first-child {
padding-top: 0;
border: none;
}
> div {
display: table-cell;
vertical-align: top;
}
.activity-log__report {
width: 80%;
}
.activity-log__action {
text-align: right;
}
.report__platform {
font-size: 16px;
font-weight: 600;
.fa {
margin-right: 6px;
}
}
.report__info {
color: #999;
}
}

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

@@ -44,14 +44,16 @@
white-space: normal; white-space: normal;
} }
} }
} }
.channel-intro { .channel-intro {
padding-bottom:5px; padding-bottom:5px;
margin: 0 1em 35px; margin: 0 1em 35px;
max-width: 850px;
border-bottom: 1px solid lightgrey; border-bottom: 1px solid lightgrey;
.intro-links { .intro-links {
margin: 0.5em 1.5em 0 0; margin: 0 1.5em 10px 0;
display: inline-block; display: inline-block;
.fa { .fa {
margin-right: 5px; margin-right: 5px;
@@ -64,8 +66,15 @@
.channel-intro-img { .channel-intro-img {
float:left; float:left;
} }
.channel-intro-title { .channel-intro__title {
font-weight:600; font-weight:600;
font-size: 20px;
margin-bottom: 15px;
}
.channel-intro__content {
background: #f7f7f7;
padding: 10px 15px;
@include border-radius(3px);
} }
.channel-intro-text { .channel-intro-text {
margin-top:35px; margin-top:35px;
@@ -106,9 +115,9 @@
height: 36px; height: 36px;
float: left; float: left;
@include border-radius(36px); @include border-radius(36px);
margin-right: 6px;
} }
.header__info { .header__info {
padding-left: 42px;
color: #fff; color: #fff;
} }
.team__name, .user__name { .team__name, .user__name {

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

@@ -215,9 +215,6 @@ body.ios {
@include opacity(1); @include opacity(1);
} }
.dropdown-toggle:after { .dropdown-toggle:after {
content: '...';
}
.dropdown-toggle:hover:after {
content: '[...]'; content: '[...]';
} }
} }
@@ -322,6 +319,12 @@ body.ios {
max-width: 100%; max-width: 100%;
@include legacy-pie-clearfix; @include legacy-pie-clearfix;
} }
&.active-thread__content {
// this still needs a final style applied to it
& .post-body {
font-weight: bold;
}
}
} }
.post-image__columns { .post-image__columns {
@include legacy-pie-clearfix; @include legacy-pie-clearfix;
@@ -437,4 +440,4 @@ body.ios {
width: 40px; width: 40px;
} }
} }
} }

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

@@ -89,6 +89,9 @@
max-width: 810px; max-width: 810px;
} }
} }
.channel-intro {
max-width: 810px;
}
.date-separator, .new-separator { .date-separator, .new-separator {
&.hovered--comment { &.hovered--comment {
&:before, &:after { &:before, &:after {
@@ -214,6 +217,12 @@
} }
} }
@media (min-width: 992px){
.modal-lg {
width: 700px;
}
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.second-bar { .second-bar {
display: none; display: none;
@@ -239,6 +248,11 @@
} }
&:hover { &:hover {
background: none; background: none;
.post-header .post-header-col.post-header__reply {
.dropdown-toggle:after {
content: '...';
}
}
} }
&.post--comment { &.post--comment {
&.other--root { &.other--root {
@@ -247,6 +261,11 @@
} }
} }
} }
.post-header .post-header-col.post-header__reply {
.dropdown-toggle:after {
content: '...';
}
}
} }
.signup-team__container { .signup-team__container {
padding: 30px 0; padding: 30px 0;
@@ -630,6 +649,33 @@
padding: 9px 21px 10px 10px !important; padding: 9px 21px 10px 10px !important;
} }
} }
@media screen and (max-width: 640px) {
.access-history__table {
> div {
display: block;
}
.access__report {
margin: 0 0 15px 15px;
}
.access__date {
div {
margin-bottom: 15px;
}
}
}
.activity-log__table {
> div {
display: block;
}
.activity-log__report {
width: 100%;
}
.activity-log__action {
text-align: left;
margin-top: 10px;
}
}
}
@media screen and (max-width: 480px) { @media screen and (max-width: 480px) {
.modal { .modal {
.modal-body { .modal-body {

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

@@ -1,3 +1,6 @@
@import "access-history";
@import "activity-log";
.user-settings { .user-settings {
background: #fff; background: #fff;
min-height:300px; min-height:300px;
@@ -32,6 +35,12 @@
display: table-cell; display: table-cell;
vertical-align: top; vertical-align: top;
} }
.security-links {
margin-right: 20px;
.fa {
margin-right: 6px;
}
}
.settings-links { .settings-links {
width: 180px; width: 180px;
background: #FAFAFA; background: #FAFAFA;
@@ -223,4 +232,4 @@
.color-btn { .color-btn {
margin:4px; margin:4px;
} }

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

@@ -15,9 +15,14 @@ Learn more, or download the source code from <a href=http://mattermost.com>http:
<p>To take part in the community building Mattermost, please consider sharing comments, feature requests, votes, and contributions. If you like the project, please Tweet about us at <a href=https://twitter.com/mattermosthq>@mattermosthq</a>.</p> <p>To take part in the community building Mattermost, please consider sharing comments, feature requests, votes, and contributions. If you like the project, please Tweet about us at <a href=https://twitter.com/mattermosthq>@mattermosthq</a>.</p>
<p>Here's some links to get started:<br> <p>Here's some links to get started:<br>
<li><a href=http://bit.ly/1dHmQqX>Mattermost source code and install instructions</a></li> <ul>
<li><a href=http://bit.ly/1JUDoZ3>Mattermost Feature Request and Voting Site</a> </li> <li><a href="https://github.com/mattermost/platform">Follow Mattermost on Github</a></li>
<li><a href=http://bit.ly/1MH9HKa>Mattermost Issue Tracker for reporting bugs</a></li> <li><a href="http://forum.mattermost.org/">Ask us anything at http://forum.mattermost.org/</a></li>
<li><a href="http://www.mattermost.org/feature-requests/">Review the Mattermost feature list </a></li>
<li><a href="http://www.mattermost.org/download/">Download our source code and install instructions</a></li>
<li><a href="http://www.mattermost.org/feature-requests/">Share feature requests and upvotes</a></li>
<li><a href="http://www.mattermost.org/filing-issues/">File any bugs you find with our Issue tracking system</a></li>
</ul>
</p> </p>
</body> </body>
</html> </html>

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

@@ -45,6 +45,8 @@
<div id="team_members_modal"></div> <div id="team_members_modal"></div>
<div id="direct_channel_modal"></div> <div id="direct_channel_modal"></div>
<div id="channel_info_modal"></div> <div id="channel_info_modal"></div>
<div id="access_history_modal"></div>
<div id="activity_log_modal"></div>
<script> <script>
window.setup_channel_page('{{ .Props.TeamDisplayName }}', '{{ .Props.TeamType }}', '{{ .Props.TeamId }}', '{{ .Props.ChannelName }}', '{{ .Props.ChannelId }}'); window.setup_channel_page('{{ .Props.TeamDisplayName }}', '{{ .Props.TeamType }}', '{{ .Props.TeamId }}', '{{ .Props.ChannelName }}', '{{ .Props.ChannelId }}');
</script> </script>

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

@@ -10,7 +10,7 @@
<div class="signup-team__container"> <div class="signup-team__container">
<h3>Sign up Complete</h3> <h3>Sign up Complete</h3>
<p>Please check your email: {{ .Props.Email }}<br> <p>Please check your email: {{ .Props.Email }}<br>
You email contains a link to set up your team</p> Your email contains a link to set up your team</p>
</div> </div>
</div> </div>
</div> </div>