mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
Merge branch 'master' into mm-1420
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -47,3 +47,7 @@ web/sass-files/sass/.sass-cache/
|
||||
*config.codekit
|
||||
*.sass-cache
|
||||
*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 /etc/init.d/mysql.server start"
|
||||
|
||||
install:
|
||||
- export PATH=$PATH:$HOME/gopath/bin
|
||||
- go get github.com/tools/godep
|
||||
- godep restore
|
||||
|
||||
before_script:
|
||||
- mysql -e "CREATE DATABASE IF NOT EXISTS mattermost_test ;" -uroot
|
||||
- mysql -e "CREATE USER 'mmuser'@'%' IDENTIFIED BY 'mostest' ;" -uroot
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -40,7 +40,7 @@ WORKDIR /go
|
||||
|
||||
#
|
||||
# Install SQL
|
||||
#
|
||||
#
|
||||
|
||||
ENV MYSQL_ROOT_PASSWORD=mostest
|
||||
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 \
|
||||
&& 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/mysql && mkdir -p /var/lib/mysql
|
||||
|
||||
@@ -88,12 +88,15 @@ ADD . /go/src/github.com/mattermost/platform
|
||||
ADD ./docker/main.cf /etc/postfix/
|
||||
|
||||
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 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
|
||||
ENTRYPOINT /go/src/github.com/mattermost/platform/docker/docker-entry.sh
|
||||
|
||||
# Create default storage directory
|
||||
RUN mkdir /mattermost/
|
||||
|
||||
# Ports
|
||||
EXPOSE 80
|
||||
|
||||
2
Makefile
2
Makefile
@@ -112,6 +112,8 @@ clean:
|
||||
rm -f web/static/js/bundle*.js
|
||||
rm -f web/static/css/styles.css
|
||||
|
||||
rm -rf data/*
|
||||
rm -rf api/data/*
|
||||
rm -rf logs/*
|
||||
|
||||
|
||||
|
||||
195
api/file.go
195
api/file.go
@@ -18,8 +18,10 @@ import (
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -27,7 +29,7 @@ import (
|
||||
)
|
||||
|
||||
func InitFile(r *mux.Router) {
|
||||
l4g.Debug("Initializing post api routes")
|
||||
l4g.Debug("Initializing file api routes")
|
||||
|
||||
sr := r.PathPrefix("/files").Subrouter()
|
||||
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) {
|
||||
if !utils.IsS3Configured() {
|
||||
c.Err = model.NewAppError("uploadFile", "Unable to upload file. Amazon S3 not configured. ", "")
|
||||
if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
c.Err = model.NewAppError("uploadFile", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "")
|
||||
c.Err.StatusCode = http.StatusNotImplemented
|
||||
return
|
||||
}
|
||||
@@ -48,13 +50,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
|
||||
props := m.Value
|
||||
@@ -94,28 +89,25 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
io.Copy(buf, file)
|
||||
|
||||
ext := filepath.Ext(files[i].Filename)
|
||||
filename := filepath.Base(files[i].Filename)
|
||||
|
||||
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) {
|
||||
options := s3.Options{}
|
||||
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())
|
||||
if err := writeFile(buf.Bytes(), path); err != nil {
|
||||
c.Err = err
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -127,13 +119,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, channelId, userId string) {
|
||||
|
||||
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 + "/"
|
||||
|
||||
for i, filename := range filenames {
|
||||
@@ -169,11 +154,8 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch
|
||||
return
|
||||
}
|
||||
|
||||
// Upload thumbnail to S3
|
||||
options := s3.Options{}
|
||||
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)
|
||||
if err := writeFile(buf.Bytes(), dest+name+"_thumb.jpg"); err != nil {
|
||||
l4g.Error("Unable to upload thumbnail channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
@@ -188,19 +170,15 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch
|
||||
}
|
||||
|
||||
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 {
|
||||
l4g.Error("Unable to encode image as preview jpg channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Upload preview to S3
|
||||
options := s3.Options{}
|
||||
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)
|
||||
if err := writeFile(buf.Bytes(), dest+name+"_preview.jpg"); err != nil {
|
||||
l4g.Error("Unable to upload preview channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
@@ -215,8 +193,8 @@ type ImageGetResult struct {
|
||||
}
|
||||
|
||||
func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.IsS3Configured() {
|
||||
c.Err = model.NewAppError("getFile", "Unable to get file. Amazon S3 not configured. ", "")
|
||||
if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
c.Err = model.NewAppError("getFile", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "")
|
||||
c.Err.StatusCode = http.StatusNotImplemented
|
||||
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)
|
||||
|
||||
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 := ""
|
||||
if len(teamId) == 26 {
|
||||
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)
|
||||
asyncGetFile(bucket, path, fileData)
|
||||
asyncGetFile(path, fileData)
|
||||
|
||||
if len(hash) > 0 && len(data) > 0 && len(teamId) == 26 {
|
||||
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
|
||||
|
||||
if f == nil {
|
||||
var f2 []byte
|
||||
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 = model.NewAppError("getFile", "Could not find file.", "path="+path)
|
||||
c.Err.StatusCode = http.StatusNotFound
|
||||
return
|
||||
}
|
||||
@@ -312,10 +264,11 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(f)
|
||||
}
|
||||
|
||||
func asyncGetFile(bucket *s3.Bucket, path string, fileData chan []byte) {
|
||||
func asyncGetFile(path string, fileData chan []byte) {
|
||||
go func() {
|
||||
data, getErr := bucket.Get(path)
|
||||
data, getErr := readFile(path)
|
||||
if getErr != nil {
|
||||
l4g.Error(getErr)
|
||||
fileData <- nil
|
||||
} else {
|
||||
fileData <- data
|
||||
@@ -329,8 +282,8 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.Err.StatusCode = http.StatusForbidden
|
||||
}
|
||||
|
||||
if !utils.IsS3Configured() {
|
||||
c.Err = model.NewAppError("getPublicLink", "Unable to get link. Amazon S3 not configured. ", "")
|
||||
if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
c.Err = model.NewAppError("getPublicLink", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "")
|
||||
c.Err.StatusCode = http.StatusNotImplemented
|
||||
return
|
||||
}
|
||||
@@ -344,15 +297,14 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
getType := matches[0][1]
|
||||
channelId := matches[0][2]
|
||||
userId := matches[0][3]
|
||||
filename = matches[0][4]
|
||||
channelId := matches[0][1]
|
||||
userId := matches[0][2]
|
||||
filename = matches[0][3]
|
||||
|
||||
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)
|
||||
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") {
|
||||
return
|
||||
@@ -374,3 +326,78 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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.", "")
|
||||
}
|
||||
}
|
||||
|
||||
181
api/file_test.go
181
api/file_test.go
@@ -38,7 +38,7 @@ func TestUploadFile(t *testing.T) {
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("files", "test.png")
|
||||
part, err := writer.CreateFormFile("files", "../test.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -68,12 +68,17 @@ func TestUploadFile(t *testing.T) {
|
||||
}
|
||||
|
||||
resp, appErr := Client.UploadFile("/files/upload", body.Bytes(), writer.FormDataContentType())
|
||||
if utils.IsS3Configured() {
|
||||
if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
if appErr != nil {
|
||||
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
|
||||
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])
|
||||
bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket)
|
||||
|
||||
fileId := strings.Split(filenames[0], ".")[0]
|
||||
|
||||
// wait a bit for files to ready
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -97,13 +100,38 @@ func TestUploadFile(t *testing.T) {
|
||||
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 {
|
||||
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 {
|
||||
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 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
|
||||
|
||||
if utils.IsS3Configured() {
|
||||
if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
@@ -169,8 +197,8 @@ func TestGetFile(t *testing.T) {
|
||||
// wait a bit for files to ready
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
if _, downErr := Client.GetFile(filenames[0], true); downErr != nil {
|
||||
t.Fatal("file get failed")
|
||||
if _, downErr := Client.GetFile(filenames[0], false); downErr != nil {
|
||||
t.Fatal(downErr)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -225,28 +253,51 @@ func TestGetFile(t *testing.T) {
|
||||
t.Fatal("Should have errored - user not logged in and link not public")
|
||||
}
|
||||
|
||||
var auth aws.Auth
|
||||
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
|
||||
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
|
||||
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)
|
||||
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
|
||||
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])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg")
|
||||
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 {
|
||||
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 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel)
|
||||
|
||||
if utils.IsS3Configured() {
|
||||
if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
@@ -350,26 +401,52 @@ func TestGetPublicLink(t *testing.T) {
|
||||
t.Fatal("should have errored, user not member of channel")
|
||||
}
|
||||
|
||||
// perform clean-up on s3
|
||||
var auth aws.Auth
|
||||
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
|
||||
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
|
||||
if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
// perform clean-up on s3
|
||||
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)
|
||||
s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region])
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg")
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg")
|
||||
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 {
|
||||
data := make(map[string]string)
|
||||
|
||||
37
api/post.go
37
api/post.go
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/mattermost/platform/store"
|
||||
"github.com/mattermost/platform/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -170,16 +171,16 @@ func CreatePost(c *Context, post *model.Post, doUpdateLastViewed bool) (*model.P
|
||||
continue
|
||||
} else if model.PartialUrlRegex.MatchString(path) {
|
||||
matches := model.PartialUrlRegex.FindAllStringSubmatch(path, -1)
|
||||
if len(matches) == 0 || len(matches[0]) < 5 {
|
||||
if len(matches) == 0 || len(matches[0]) < 4 {
|
||||
doRemove = true
|
||||
}
|
||||
|
||||
channelId := matches[0][2]
|
||||
channelId := matches[0][1]
|
||||
if channelId != post.ChannelId {
|
||||
doRemove = true
|
||||
}
|
||||
|
||||
userId := matches[0][3]
|
||||
userId := matches[0][2]
|
||||
if userId != post.UserId {
|
||||
doRemove = true
|
||||
}
|
||||
@@ -407,6 +408,36 @@ func fireAndForgetNotifications(post *model.Post, teamId, siteURL string) {
|
||||
bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message)
|
||||
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 {
|
||||
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 = 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}
|
||||
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}}
|
||||
|
||||
43
api/user.go
43
api/user.go
@@ -7,8 +7,6 @@ import (
|
||||
"bytes"
|
||||
l4g "code.google.com/p/log4go"
|
||||
"fmt"
|
||||
"github.com/goamz/goamz/aws"
|
||||
"github.com/goamz/goamz/s3"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mattermost/platform/model"
|
||||
"github.com/mattermost/platform/store"
|
||||
@@ -598,7 +596,7 @@ func createProfileImage(username string, userId string) ([]byte, *model.AppError
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
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 {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -613,34 +611,25 @@ func getProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
} else {
|
||||
var img []byte
|
||||
var err *model.AppError
|
||||
|
||||
if !utils.IsS3Configured() {
|
||||
img, err = createProfileImage(result.Data.(*model.User).Username, id)
|
||||
if err != nil {
|
||||
if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
var err *model.AppError
|
||||
if img, err = createProfileImage(result.Data.(*model.User).Username, id); err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
} 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"
|
||||
|
||||
if data, getErr := bucket.Get(path); getErr != nil {
|
||||
img, err = createProfileImage(result.Data.(*model.User).Username, id)
|
||||
if err != nil {
|
||||
if data, err := readFile(path); err != nil {
|
||||
|
||||
if img, err = createProfileImage(result.Data.(*model.User).Username, id); err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
options := s3.Options{}
|
||||
if err := bucket.Put(path, img, "image", s3.Private, options); err != nil {
|
||||
c.Err = model.NewAppError("getImage", "Couldn't upload default profile image", err.Error())
|
||||
if err := writeFile(img, path); err != nil {
|
||||
c.Err = err
|
||||
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) {
|
||||
if !utils.IsS3Configured() {
|
||||
c.Err = model.NewAppError("uploadProfileImage", "Unable to upload image. Amazon S3 not configured. ", "")
|
||||
if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
c.Err = model.NewAppError("uploadProfileImage", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "")
|
||||
c.Err.StatusCode = http.StatusNotImplemented
|
||||
return
|
||||
}
|
||||
@@ -671,13 +660,6 @@ func uploadProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
|
||||
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"
|
||||
|
||||
options := s3.Options{}
|
||||
if err := bucket.Put(path, buf.Bytes(), "image", s3.Private, options); err != nil {
|
||||
if err := writeFile(buf.Bytes(), path); err != nil {
|
||||
c.Err = model.NewAppError("uploadProfileImage", "Couldn't upload profile image", "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -363,6 +363,24 @@ func TestUserCreateImage(t *testing.T) {
|
||||
|
||||
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) {
|
||||
@@ -375,7 +393,7 @@ func TestUserUploadProfileImage(t *testing.T) {
|
||||
user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User)
|
||||
store.Must(Srv.Store.User().VerifyEmail(user.Id))
|
||||
|
||||
if utils.IsS3Configured() {
|
||||
if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage {
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
@@ -443,15 +461,22 @@ func TestUserUploadProfileImage(t *testing.T) {
|
||||
|
||||
Client.DoGet("/users/"+user.Id+"/image", "", "")
|
||||
|
||||
var auth aws.Auth
|
||||
auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId
|
||||
auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body := &bytes.Buffer{}
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
"InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6",
|
||||
"PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4",
|
||||
"ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t",
|
||||
"AnalyticsUrl": ""
|
||||
"AnalyticsUrl": "",
|
||||
"UseLocalStorage": true,
|
||||
"StorageDirectory": "./data/"
|
||||
},
|
||||
"SqlSettings": {
|
||||
"DriverName": "mysql",
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
"InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6",
|
||||
"PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4",
|
||||
"ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t",
|
||||
"AnalyticsUrl": ""
|
||||
"AnalyticsUrl": "",
|
||||
"UseLocalStorage": true,
|
||||
"StorageDirectory": "/mattermost/data/"
|
||||
},
|
||||
"SqlSettings": {
|
||||
"DriverName": "mysql",
|
||||
|
||||
@@ -552,7 +552,7 @@ func (c *Client) GetFile(url string, isFullUrl bool) (*Result, *AppError) {
|
||||
if isFullUrl {
|
||||
rq, _ = http.NewRequest("GET", url, nil)
|
||||
} else {
|
||||
rq, _ = http.NewRequest("GET", c.Url+url, nil)
|
||||
rq, _ = http.NewRequest("GET", c.Url+"/files/get"+url, nil)
|
||||
}
|
||||
|
||||
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+_~\-\.%=&]*)?)?(?:#[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}
|
||||
|
||||
@@ -18,16 +18,18 @@ const (
|
||||
)
|
||||
|
||||
type ServiceSettings struct {
|
||||
SiteName string
|
||||
Mode string
|
||||
AllowTesting bool
|
||||
UseSSL bool
|
||||
Port string
|
||||
Version string
|
||||
InviteSalt string
|
||||
PublicLinkSalt string
|
||||
ResetSalt string
|
||||
AnalyticsUrl string
|
||||
SiteName string
|
||||
Mode string
|
||||
AllowTesting bool
|
||||
UseSSL bool
|
||||
Port string
|
||||
Version string
|
||||
InviteSalt string
|
||||
PublicLinkSalt string
|
||||
ResetSalt string
|
||||
AnalyticsUrl string
|
||||
UseLocalStorage bool
|
||||
StorageDirectory string
|
||||
}
|
||||
|
||||
type SqlSettings struct {
|
||||
|
||||
19
utils/urlencode.go
Normal file
19
utils/urlencode.go
Normal file
@@ -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
Normal file
100
web/react/components/access_history_modal.jsx
Normal file
@@ -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">×</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
Normal file
116
web/react/components/activity_log_modal.jsx
Normal file
@@ -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">×</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 SocketStore = require('../stores/socket_store.jsx');
|
||||
var ChannelStore = require('../stores/channel_store.jsx');
|
||||
var PostStore = require('../stores/post_store.jsx');
|
||||
var Textbox = require('./textbox.jsx');
|
||||
var MsgTyping = require('./msg_typing.jsx');
|
||||
var FileUpload = require('./file_upload.jsx');
|
||||
@@ -43,6 +44,7 @@ module.exports = React.createClass({
|
||||
|
||||
client.createPost(post, ChannelStore.getCurrent(),
|
||||
function(data) {
|
||||
PostStore.storeCommentDraft(this.props.rootId, null);
|
||||
this.setState({ messageText: '', submitting: false, post_error: null, server_error: null });
|
||||
this.clearPreviews();
|
||||
AsyncClient.getPosts(true, this.props.channelId);
|
||||
@@ -82,16 +84,33 @@ module.exports = React.createClass({
|
||||
}
|
||||
},
|
||||
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").perfectScrollbar('update');
|
||||
this.setState({messageText: messageText});
|
||||
},
|
||||
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").perfectScrollbar('update');
|
||||
var oldPreviews = this.state.previews;
|
||||
var previews = this.state.previews.concat(newPreviews);
|
||||
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) {
|
||||
this.setState({ server_error: err });
|
||||
@@ -107,10 +126,43 @@ module.exports = React.createClass({
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var draft = PostStore.getCommentDraft();
|
||||
if (!draft) {
|
||||
draft = { message: '', uploadsInProgress: 0};
|
||||
}
|
||||
draft.previews = previews;
|
||||
PostStore.storeCommentDraft(draft);
|
||||
|
||||
this.setState({previews: previews});
|
||||
},
|
||||
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) {
|
||||
var oldInProgress = this.state.uploadsInProgress
|
||||
@@ -126,6 +178,13 @@ module.exports = React.createClass({
|
||||
var numToUpload = newInProgress - oldInProgress;
|
||||
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});
|
||||
|
||||
return numToUpload;
|
||||
|
||||
@@ -31,6 +31,11 @@ module.exports = React.createClass({
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -50,7 +55,7 @@ module.exports = React.createClass({
|
||||
post.message,
|
||||
false,
|
||||
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 });
|
||||
|
||||
if (data.goto_location.length > 0) {
|
||||
@@ -68,9 +73,12 @@ module.exports = React.createClass({
|
||||
post.channel_id = this.state.channel_id;
|
||||
post.filenames = this.state.previews;
|
||||
|
||||
post.root_id = this.state.rootId;
|
||||
post.parent_id = this.state.parentId;
|
||||
|
||||
client.createPost(post, ChannelStore.getCurrent(),
|
||||
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.resizePostHolder();
|
||||
AsyncClient.getPosts(true);
|
||||
@@ -84,7 +92,13 @@ module.exports = React.createClass({
|
||||
}.bind(this),
|
||||
function(err) {
|
||||
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;
|
||||
this.setState(state);
|
||||
}.bind(this)
|
||||
@@ -92,6 +106,17 @@ module.exports = React.createClass({
|
||||
}
|
||||
|
||||
$(".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() {
|
||||
this.resizePostHolder();
|
||||
@@ -112,6 +137,63 @@ module.exports = React.createClass({
|
||||
handleUserInput: function(messageText) {
|
||||
this.resizePostHolder();
|
||||
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();
|
||||
if (!draft) {
|
||||
draft = {}
|
||||
@@ -127,7 +209,7 @@ module.exports = React.createClass({
|
||||
$(window).trigger('resize');
|
||||
},
|
||||
handleFileUpload: function(newPreviews, channel_id) {
|
||||
var draft = PostStore.getDraft(channel_id, UserStore.getCurrentId());
|
||||
var draft = PostStore.getDraft(channel_id);
|
||||
if (!draft) {
|
||||
draft = {}
|
||||
draft['message'] = '';
|
||||
@@ -148,7 +230,7 @@ module.exports = React.createClass({
|
||||
} else {
|
||||
draft['previews'] = draft['previews'].concat(newPreviews);
|
||||
draft['uploadsInProgress'] = draft['uploadsInProgress'] > 0 ? draft['uploadsInProgress'] - 1 : 0;
|
||||
PostStore.storeDraft(channel_id, UserStore.getCurrentId(), draft);
|
||||
PostStore.storeDraft(channel_id, draft);
|
||||
}
|
||||
},
|
||||
handleUploadError: function(err) {
|
||||
@@ -174,10 +256,12 @@ module.exports = React.createClass({
|
||||
},
|
||||
componentDidMount: function() {
|
||||
ChannelStore.addChangeListener(this._onChange);
|
||||
PostStore.addActiveThreadChangedListener(this._onActiveThreadChanged);
|
||||
this.resizePostHolder();
|
||||
},
|
||||
componentWillUnmount: function() {
|
||||
ChannelStore.removeChangeListener(this._onChange);
|
||||
PostStore.removeActiveThreadChangedListener(this._onActiveThreadChanged);
|
||||
},
|
||||
_onChange: function() {
|
||||
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 });
|
||||
}
|
||||
},
|
||||
_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() {
|
||||
PostStore.clearDraftUploads();
|
||||
|
||||
@@ -204,7 +293,7 @@ module.exports = React.createClass({
|
||||
previews = draft['previews'];
|
||||
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) {
|
||||
var oldInProgress = this.state.uploadsInProgress
|
||||
|
||||
@@ -6,17 +6,24 @@ var AsyncClient = require('../utils/async_client.jsx');
|
||||
|
||||
module.exports = React.createClass({
|
||||
handleEdit: function(e) {
|
||||
var data = {}
|
||||
var data = {};
|
||||
data["channel_id"] = this.state.channel_id;
|
||||
if (data["channel_id"].length !== 26) return;
|
||||
data["channel_description"] = this.state.description.trim();
|
||||
|
||||
Client.updateChannelDesc(data,
|
||||
function(data) {
|
||||
this.setState({ server_error: "" });
|
||||
AsyncClient.getChannels(true);
|
||||
$(this.refs.modal.getDOMNode()).modal('hide');
|
||||
}.bind(this),
|
||||
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)
|
||||
);
|
||||
},
|
||||
@@ -27,13 +34,15 @@ module.exports = React.createClass({
|
||||
var self = this;
|
||||
$(this.refs.modal.getDOMNode()).on('show.bs.modal', function(e) {
|
||||
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() {
|
||||
return { description: "", title: "", channel_id: "" };
|
||||
},
|
||||
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 (
|
||||
<div className="modal fade" ref="modal" id="edit_channel" role="dialog" aria-hidden="true">
|
||||
<div className="modal-dialog">
|
||||
@@ -44,10 +53,11 @@ module.exports = React.createClass({
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<textarea className="form-control no-resize" rows="6" ref="channelDesc" maxLength="1024" value={this.state.description} onChange={this.handleUserInput}></textarea>
|
||||
{ server_error }
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<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>
|
||||
|
||||
@@ -16,20 +16,26 @@ module.exports = React.createClass({
|
||||
var previews = [];
|
||||
this.props.files.forEach(function(filename) {
|
||||
|
||||
var originalFilename = filename;
|
||||
var filenameSplit = filename.split('.');
|
||||
var ext = filenameSplit[filenameSplit.length-1];
|
||||
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") {
|
||||
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}/>
|
||||
<a className="remove-preview" onClick={this.handleRemove}><i className="glyphicon glyphicon-remove"/></a>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
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)}/>
|
||||
<a className="remove-preview" onClick={this.handleRemove}><i className="glyphicon glyphicon-remove"/></a>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ function getCountsStateFromStores() {
|
||||
} else {
|
||||
if (channelMember.mention_count > 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
</div>
|
||||
: 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} />
|
||||
<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" />
|
||||
|
||||
@@ -28,6 +28,12 @@ module.exports = React.createClass({
|
||||
|
||||
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") {
|
||||
$('<img/>').attr('src', fileInfo.path+'_thumb.jpg').load(function(path, name){ return function() {
|
||||
$(this).remove();
|
||||
@@ -102,6 +108,12 @@ module.exports = React.createClass({
|
||||
|
||||
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 (i < Constants.MAX_DISPLAY_FILES) {
|
||||
postFiles.push(
|
||||
|
||||
@@ -22,7 +22,8 @@ function getStateFromStores() {
|
||||
|
||||
return {
|
||||
post_list: PostStore.getCurrentPosts(),
|
||||
channel: channel
|
||||
channel: channel,
|
||||
activeThreadRootId: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ module.exports = React.createClass({
|
||||
ChannelStore.addChangeListener(this._onChange);
|
||||
UserStore.addStatusesChangeListener(this._onTimeChange);
|
||||
SocketStore.addChangeListener(this._onSocketChange);
|
||||
PostStore.addActiveThreadChangedListener(this._onActiveThreadChanged);
|
||||
|
||||
$(".post-list-holder-by-time").perfectScrollbar();
|
||||
|
||||
@@ -131,6 +133,7 @@ module.exports = React.createClass({
|
||||
ChannelStore.removeChangeListener(this._onChange);
|
||||
UserStore.removeStatusesChangeListener(this._onTimeChange);
|
||||
SocketStore.removeChangeListener(this._onSocketChange);
|
||||
PostStore.removeActiveThreadChangedListener(this._onActiveThreadChanged);
|
||||
$('body').off('click.userpopover');
|
||||
},
|
||||
resize: function() {
|
||||
@@ -223,11 +226,15 @@ module.exports = React.createClass({
|
||||
}
|
||||
},
|
||||
_onTimeChange: function() {
|
||||
if (!this.state.post_list) return;
|
||||
for (var id in this.state.post_list.posts) {
|
||||
if (!this.refs[id]) continue;
|
||||
this.refs[id].forceUpdateInfo();
|
||||
}
|
||||
},
|
||||
_onActiveThreadChanged: function(rootId, parentId) {
|
||||
this.setState({"activeThreadRootId": rootId});
|
||||
},
|
||||
getMorePosts: function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -347,8 +354,8 @@ module.exports = React.createClass({
|
||||
if (ChannelStore.isDefault(channel)) {
|
||||
more_messages = (
|
||||
<div className="channel-intro">
|
||||
<h4 className="channel-intro-title">Welcome</h4>
|
||||
<p>
|
||||
<h4 className="channel-intro__title">Beginning of {ui_name}</h4>
|
||||
<p className="channel-intro__content">
|
||||
Welcome to {ui_name}!
|
||||
<br/><br/>
|
||||
{"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) {
|
||||
more_messages = (
|
||||
<div className="channel-intro">
|
||||
<h4 className="channel-intro-title">Welcome</h4>
|
||||
<p>
|
||||
<h4 className="channel-intro__title">Beginning of {ui_name}</h4>
|
||||
<p className="channel-intro__content">
|
||||
{"This is the start of " + ui_name + ", a channel for conversations you’d prefer out of more focused channels."}
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
} else {
|
||||
var ui_type = channel.type === 'P' ? "private group" : "channel";
|
||||
more_messages = (
|
||||
<div className="channel-intro">
|
||||
<h4 className="channel-intro-title">Welcome</h4>
|
||||
<p>
|
||||
<h4 className="channel-intro__title">Beginning of {ui_name}</h4>
|
||||
<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) + "."
|
||||
: "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." }
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
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);
|
||||
if (currentPostDay.toDateString() != previousPostDay.toDateString()) {
|
||||
|
||||
@@ -91,28 +91,27 @@ RootPost = React.createClass({
|
||||
var re2 = new RegExp('\\(', 'g');
|
||||
var re3 = new RegExp('\\)', 'g');
|
||||
for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) {
|
||||
var fileSplit = filenames[i].split('.');
|
||||
if (fileSplit.length < 2) continue;
|
||||
var fileInfo = utils.splitFileLocation(filenames[i]);
|
||||
var ftype = utils.getFileType(fileInfo.ext);
|
||||
|
||||
var ext = fileSplit[fileSplit.length-1];
|
||||
fileSplit.splice(fileSplit.length-1,1);
|
||||
var filePath = fileSplit.join('.');
|
||||
var filename = filePath.split('/')[filePath.split('/').length-1];
|
||||
|
||||
var ftype = 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 (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(
|
||||
<div className="post-image__column" key={filePath}>
|
||||
<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>
|
||||
<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={fileInfo.path} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a>
|
||||
</div>
|
||||
);
|
||||
images.push(filenames[i]);
|
||||
} else {
|
||||
postFiles.push(
|
||||
<div className="post-image__column custom-file" key={filePath}>
|
||||
<a href={filePath+"."+ext} download={filename+"."+ext}>
|
||||
<div className="post-image__column custom-file" key={fileInfo.path}>
|
||||
<a href={fileInfo.path+"."+ext} download={fileInfo.name+"."+ext}>
|
||||
<div className={"file-icon "+utils.getIconClassName(ftype)}/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -201,28 +200,28 @@ CommentPost = React.createClass({
|
||||
var re2 = new RegExp('\\(', 'g');
|
||||
var re3 = new RegExp('\\)', 'g');
|
||||
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];
|
||||
fileSplit.splice(fileSplit.length-1,1)
|
||||
var filePath = fileSplit.join('.');
|
||||
var filename = filePath.split('/')[filePath.split('/').length-1];
|
||||
var fileInfo = utils.splitFileLocation(filenames[i]);
|
||||
var type = utils.getFileType(fileInfo.ext);
|
||||
|
||||
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") {
|
||||
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(
|
||||
<div className="post-image__column" key={filename}>
|
||||
<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>
|
||||
<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={fileInfo.path} className="post__image" style={{backgroundImage: 'url(' + url + '_thumb.jpg)'}}></div></a>
|
||||
</div>
|
||||
);
|
||||
images.push(filenames[i]);
|
||||
} else {
|
||||
postFiles.push(
|
||||
<div className="post-image__column custom-file" key={filename}>
|
||||
<a href={filePath+"."+ext} download={filename+"."+ext}>
|
||||
<div className="post-image__column custom-file" key={fileInfo.path}>
|
||||
<a href={fileInfo.path+"."+fileInfo.ext} download={fileInfo.name+"."+fileInfo.ext}>
|
||||
<div className={"file-icon "+utils.getIconClassName(type)}/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -294,6 +293,8 @@ module.exports = React.createClass({
|
||||
});
|
||||
},
|
||||
componentDidUpdate: function() {
|
||||
$(".post-right__scroll").scrollTop($(".post-right__scroll")[0].scrollHeight);
|
||||
$(".post-right__scroll").perfectScrollbar('update');
|
||||
this.resize();
|
||||
},
|
||||
componentWillUnmount: function() {
|
||||
@@ -352,6 +353,7 @@ module.exports = React.createClass({
|
||||
$(".post-right__scroll").css("height", height + "px");
|
||||
$(".post-right__scroll").scrollTop(100000);
|
||||
$(".post-right__scroll").perfectScrollbar();
|
||||
$(".post-right__scroll").perfectScrollbar('update');
|
||||
},
|
||||
render: function() {
|
||||
|
||||
|
||||
@@ -115,7 +115,11 @@ module.exports = React.createClass({
|
||||
return (
|
||||
<div className="team__header theme">
|
||||
<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} />
|
||||
:
|
||||
<div />
|
||||
}
|
||||
<div className="header__info">
|
||||
<div className="user__name">{ '@' + me.username}</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 SettingItemMax = require('./setting_item_max.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 AsyncClient = require('../utils/async_client.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">×</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">×</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({
|
||||
submitPassword: function(e) {
|
||||
e.preventDefault();
|
||||
@@ -637,6 +496,12 @@ var SecurityTab = React.createClass({
|
||||
updateConfirmPassword: function(e) {
|
||||
this.setState({ confirm_password: e.target.value });
|
||||
},
|
||||
handleHistoryOpen: function() {
|
||||
$("#user_settings1").modal('hide');
|
||||
},
|
||||
handleDevicesOpen: function() {
|
||||
$("#user_settings1").modal('hide');
|
||||
},
|
||||
getInitialState: function() {
|
||||
return { current_password: '', new_password: '', confirm_password: '' };
|
||||
},
|
||||
@@ -711,6 +576,10 @@ var SecurityTab = React.createClass({
|
||||
<div className="divider-dark first"/>
|
||||
{ passwordSection }
|
||||
<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>
|
||||
);
|
||||
@@ -1225,23 +1094,6 @@ module.exports = React.createClass({
|
||||
<NotificationsTab user={this.state.user} activeSection={this.props.activeSection} updateSection={this.props.updateSection} />
|
||||
</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') {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -30,8 +30,6 @@ module.exports = React.createClass({
|
||||
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: "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 (
|
||||
<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];
|
||||
} else {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -139,18 +144,30 @@ module.exports = React.createClass({
|
||||
if (this.props.imgCount > 0) {
|
||||
preview_filename = this.props.filenames[this.state.imgId];
|
||||
} 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';
|
||||
}
|
||||
|
||||
var imgClass = "hidden";
|
||||
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);
|
||||
|
||||
// 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 (
|
||||
<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">
|
||||
@@ -168,7 +185,7 @@ module.exports = React.createClass({
|
||||
<span className="text"> | </span>
|
||||
</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>
|
||||
{loading}
|
||||
|
||||
@@ -32,6 +32,8 @@ var ErrorBar = require('../components/error_bar.jsx')
|
||||
var ChannelLoader = require('../components/channel_loader.jsx');
|
||||
var MentionList = require('../components/mention_list.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');
|
||||
@@ -205,4 +207,14 @@ global.window.setup_channel_page = function(team_name, team_type, team_id, chann
|
||||
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 MENTION_DATA_CHANGE_EVENT = 'mention_data_change';
|
||||
var ADD_MENTION_EVENT = 'add_mention';
|
||||
var ACTIVE_THREAD_CHANGED_EVENT = 'active_thread_changed';
|
||||
|
||||
var PostStore = assign({}, EventEmitter.prototype, {
|
||||
|
||||
@@ -93,6 +94,18 @@ var PostStore = assign({}, EventEmitter.prototype, {
|
||||
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() {
|
||||
var currentId = ChannelStore.getCurrentId();
|
||||
|
||||
@@ -136,19 +149,23 @@ var PostStore = assign({}, EventEmitter.prototype, {
|
||||
},
|
||||
storeCurrentDraft: function(draft) {
|
||||
var channel_id = ChannelStore.getCurrentId();
|
||||
var user_id = UserStore.getCurrentId();
|
||||
BrowserStore.setItem("draft_" + channel_id + "_" + user_id, draft);
|
||||
BrowserStore.setItem("draft_" + channel_id, draft);
|
||||
},
|
||||
getCurrentDraft: function() {
|
||||
var channel_id = ChannelStore.getCurrentId();
|
||||
var user_id = UserStore.getCurrentId();
|
||||
return BrowserStore.getItem("draft_" + channel_id + "_" + user_id);
|
||||
return BrowserStore.getItem("draft_" + channel_id);
|
||||
},
|
||||
storeDraft: function(channel_id, user_id, draft) {
|
||||
BrowserStore.setItem("draft_" + channel_id + "_" + user_id, draft);
|
||||
storeDraft: function(channel_id, draft) {
|
||||
BrowserStore.setItem("draft_" + channel_id, draft);
|
||||
},
|
||||
getDraft: function(channel_id, user_id) {
|
||||
return BrowserStore.getItem("draft_" + channel_id + "_" + user_id);
|
||||
getDraft: function(channel_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() {
|
||||
BrowserStore.actionOnItemsWithPrefix("draft_", function (key, value) {
|
||||
@@ -157,6 +174,14 @@ var PostStore = assign({}, EventEmitter.prototype, {
|
||||
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:
|
||||
PostStore.emitAddMention(action.id, action.username);
|
||||
break;
|
||||
case ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED:
|
||||
PostStore.emitActiveThreadChanged(action.root_id, action.parent_id);
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ module.exports = {
|
||||
RECIEVED_POST_SELECTED: null,
|
||||
RECIEVED_MENTION_DATA: null,
|
||||
RECIEVED_ADD_MENTION: null,
|
||||
RECEIVED_ACTIVE_THREAD_CHANGED: null,
|
||||
|
||||
RECIEVED_PROFILES: null,
|
||||
RECIEVED_ME: null,
|
||||
|
||||
29
web/sass-files/sass/partials/_access-history.scss
Normal file
29
web/sass-files/sass/partials/_access-history.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
31
web/sass-files/sass/partials/_activity-log.scss
Normal file
31
web/sass-files/sass/partials/_activity-log.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.channel-intro {
|
||||
padding-bottom:5px;
|
||||
margin: 0 1em 35px;
|
||||
max-width: 850px;
|
||||
border-bottom: 1px solid lightgrey;
|
||||
.intro-links {
|
||||
margin: 0.5em 1.5em 0 0;
|
||||
margin: 0 1.5em 10px 0;
|
||||
display: inline-block;
|
||||
.fa {
|
||||
margin-right: 5px;
|
||||
@@ -64,8 +66,15 @@
|
||||
.channel-intro-img {
|
||||
float:left;
|
||||
}
|
||||
.channel-intro-title {
|
||||
.channel-intro__title {
|
||||
font-weight:600;
|
||||
font-size: 20px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.channel-intro__content {
|
||||
background: #f7f7f7;
|
||||
padding: 10px 15px;
|
||||
@include border-radius(3px);
|
||||
}
|
||||
.channel-intro-text {
|
||||
margin-top:35px;
|
||||
@@ -106,9 +115,9 @@
|
||||
height: 36px;
|
||||
float: left;
|
||||
@include border-radius(36px);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.header__info {
|
||||
padding-left: 42px;
|
||||
color: #fff;
|
||||
}
|
||||
.team__name, .user__name {
|
||||
|
||||
@@ -215,9 +215,6 @@ body.ios {
|
||||
@include opacity(1);
|
||||
}
|
||||
.dropdown-toggle:after {
|
||||
content: '...';
|
||||
}
|
||||
.dropdown-toggle:hover:after {
|
||||
content: '[...]';
|
||||
}
|
||||
}
|
||||
@@ -322,6 +319,12 @@ body.ios {
|
||||
max-width: 100%;
|
||||
@include legacy-pie-clearfix;
|
||||
}
|
||||
&.active-thread__content {
|
||||
// this still needs a final style applied to it
|
||||
& .post-body {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
.post-image__columns {
|
||||
@include legacy-pie-clearfix;
|
||||
@@ -437,4 +440,4 @@ body.ios {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@
|
||||
max-width: 810px;
|
||||
}
|
||||
}
|
||||
.channel-intro {
|
||||
max-width: 810px;
|
||||
}
|
||||
.date-separator, .new-separator {
|
||||
&.hovered--comment {
|
||||
&:before, &:after {
|
||||
@@ -214,6 +217,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px){
|
||||
.modal-lg {
|
||||
width: 700px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.second-bar {
|
||||
display: none;
|
||||
@@ -239,6 +248,11 @@
|
||||
}
|
||||
&:hover {
|
||||
background: none;
|
||||
.post-header .post-header-col.post-header__reply {
|
||||
.dropdown-toggle:after {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
}
|
||||
&.post--comment {
|
||||
&.other--root {
|
||||
@@ -247,6 +261,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.post-header .post-header-col.post-header__reply {
|
||||
.dropdown-toggle:after {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
}
|
||||
.signup-team__container {
|
||||
padding: 30px 0;
|
||||
@@ -630,6 +649,33 @@
|
||||
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) {
|
||||
.modal {
|
||||
.modal-body {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
@import "access-history";
|
||||
@import "activity-log";
|
||||
|
||||
.user-settings {
|
||||
background: #fff;
|
||||
min-height:300px;
|
||||
@@ -32,6 +35,12 @@
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
}
|
||||
.security-links {
|
||||
margin-right: 20px;
|
||||
.fa {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
.settings-links {
|
||||
width: 180px;
|
||||
background: #FAFAFA;
|
||||
@@ -223,4 +232,4 @@
|
||||
|
||||
.color-btn {
|
||||
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>Here's some links to get started:<br>
|
||||
<li><a href=http://bit.ly/1dHmQqX>Mattermost source code and install instructions</a></li>
|
||||
<li><a href=http://bit.ly/1JUDoZ3>Mattermost Feature Request and Voting Site</a> </li>
|
||||
<li><a href=http://bit.ly/1MH9HKa>Mattermost Issue Tracker for reporting bugs</a></li>
|
||||
<ul>
|
||||
<li><a href="https://github.com/mattermost/platform">Follow Mattermost on Github</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
<div id="team_members_modal"></div>
|
||||
<div id="direct_channel_modal"></div>
|
||||
<div id="channel_info_modal"></div>
|
||||
<div id="access_history_modal"></div>
|
||||
<div id="activity_log_modal"></div>
|
||||
<script>
|
||||
window.setup_channel_page('{{ .Props.TeamDisplayName }}', '{{ .Props.TeamType }}', '{{ .Props.TeamId }}', '{{ .Props.ChannelName }}', '{{ .Props.ChannelId }}');
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="signup-team__container">
|
||||
<h3>Sign up Complete</h3>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user