Merge branch 'master' into mm-1420

This commit is contained in:
=Corey Hulen
2015-07-22 18:05:44 -08:00
44 changed files with 1069 additions and 435 deletions

4
.gitignore vendored
View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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/*

View File

@@ -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.", "")
}
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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}}

View File

@@ -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
}

View File

@@ -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{}

View File

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

View File

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

View File

@@ -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 {

View File

@@ -319,6 +319,6 @@ func ClearMentionTags(post string) string {
}
var UrlRegex = regexp.MustCompile(`^((?:[a-z]+:\/\/)?(?:(?:[a-z0-9\-]+\.)+(?:[a-z]{2}|aero|arpa|biz|com|coop|edu|gov|info|int|jobs|mil|museum|name|nato|net|org|pro|travel|local|internal))(:[0-9]{1,5})?(?:\/[a-z0-9_\-\.~]+)*(\/([a-z0-9_\-\.]*)(?:\?[a-z0-9+_~\-\.%=&amp;]*)?)?(?:#[a-zA-Z0-9!$&'()*+.=-_~:@/?]*)?)(?:\s+|$)$`)
var 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}

View File

@@ -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
View 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")
}

View 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">&times;</span></button>
<h4 className="modal-title" id="myModalLabel">Access History</h4>
</div>
<div ref="modalBody" className="modal-body">
<form role="form">
{ accessList }
</form>
</div>
</div>
</div>
</div>
</div>
);
}
});

View 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">&times;</span></button>
<h4 className="modal-title" id="myModalLabel">Active Devices</h4>
</div>
<div ref="modalBody" className="modal-body">
<form role="form">
{ activityList }
</form>
{ server_error }
</div>
</div>
</div>
</div>
</div>
);
}
});

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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" />

View File

@@ -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(

View File

@@ -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 youd 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()) {

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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">&times;</span></button>
<h4 className="modal-title" ref="title"><i className="modal-back"></i>Sessions</h4>
</div>
<div className="user-settings">
<h3 className="tab-header">Sessions</h3>
<div className="divider-dark first"/>
{ server_error }
<div className="table-responsive" style={{ maxWidth: "560px", maxHeight: "300px" }}>
<table className="table-condensed small">
<thead>
<tr><th>Id</th><th>Platform</th><th>OS</th><th>Browser</th><th>Created</th><th>Last Activity</th><th>Revoke</th></tr>
</thead>
<tbody>
{
this.state.sessions.map(function(value, index) {
return (
<tr key={ "" + index }>
<td style={{ whiteSpace: "nowrap" }}>{ value.alt_id }</td>
<td style={{ whiteSpace: "nowrap" }}>{value.props.platform}</td>
<td style={{ whiteSpace: "nowrap" }}>{value.props.os}</td>
<td style={{ whiteSpace: "nowrap" }}>{value.props.browser}</td>
<td style={{ whiteSpace: "nowrap" }}>{ new Date(value.create_at).toLocaleString() }</td>
<td style={{ whiteSpace: "nowrap" }}>{ new Date(value.last_activity_at).toLocaleString() }</td>
<td><button onClick={this.submitRevoke.bind(this, value.alt_id)} className="pull-right btn btn-primary">Revoke</button></td>
</tr>
);
}, this)
}
</tbody>
</table>
</div>
<div className="divider-dark"/>
</div>
</div>
);
}
});
function getStateFromStoresForAudits() {
return {
audits: UserStore.getAudits()
};
}
var AuditTab = React.createClass({
componentDidMount: function() {
UserStore.addAuditsChangeListener(this._onChange);
AsyncClient.getAudits();
},
componentWillUnmount: function() {
UserStore.removeAuditsChangeListener(this._onChange);
},
_onChange: function() {
this.setState(getStateFromStoresForAudits());
},
getInitialState: function() {
return getStateFromStoresForAudits();
},
render: function() {
return (
<div>
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 className="modal-title" ref="title"><i className="modal-back"></i>Activity Log</h4>
</div>
<div className="user-settings">
<h3 className="tab-header">Activity Log</h3>
<div className="divider-dark first"/>
<div className="table-responsive">
<table className="table-condensed small">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>IP Address</th>
<th>Session</th>
<th>Other Info</th>
</tr>
</thead>
<tbody>
{
this.state.audits.map(function(value, index) {
return (
<tr key={ "" + index }>
<td className="text-nowrap">{ new Date(value.create_at).toLocaleString() }</td>
<td className="text-nowrap">{ value.action.replace("/api/v1", "") }</td>
<td className="text-nowrap">{ value.ip_address }</td>
<td className="text-nowrap">{ value.session_id }</td>
<td className="text-nowrap">{ value.extra_info }</td>
</tr>
);
}, this)
}
</tbody>
</table>
</div>
<div className="divider-dark"/>
</div>
</div>
);
}
});
var SecurityTab = React.createClass({
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>

View File

@@ -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">

View File

@@ -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}

View File

@@ -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')
);
};

View File

@@ -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:
}

View File

@@ -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,

View 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;
}
}

View 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;
}
}

View File

@@ -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 {

View File

@@ -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;
}
}
}
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>