diff options
44 files changed, 1080 insertions, 446 deletions
diff --git a/.gitignore b/.gitignore index c899ddd8f..b8017c198 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ web/sass-files/sass/.sass-cache/ *config.codekit *.sass-cache *styles.css + +# Default local file storage +data/* +api/data/* diff --git a/.travis.yml b/.travis.yml index 55ab2986e..fc2fb7646 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 5c389c056..e6c04d541 100644 --- a/Dockerfile +++ b/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 @@ -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/* diff --git a/api/file.go b/api/file.go index 362cdf896..82cee9d1e 100644 --- a/api/file.go +++ b/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 := writeFile(buf.Bytes(), path); err != nil { + c.Err = err + return } - if err != nil { - c.Err = model.NewAppError("uploadFile", "Unable to upload file. ", err.Error()) - return + if model.IsFileExtImage(filepath.Ext(files[i].Filename)) { + imageNameList = append(imageNameList, uid+"/"+filename) + imageDataList = append(imageDataList, buf.Bytes()) } - fileUrl := c.GetSiteURL() + "/api/v1/files/get/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + url.QueryEscape(files[i].Filename) + 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.", "") + } +} diff --git a/api/file_test.go b/api/file_test.go index 79ee03c77..566fd69d0 100644 --- a/api/file_test.go +++ b/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 - - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - - fileId := strings.Split(filenames[0], ".")[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 + "/" + 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) + 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) + + 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 + "/" + 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 + "_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 - - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - - fileId := strings.Split(filenames[0], ".")[0] - - if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + filenames[0]); 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) - } - - if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + fileId + "_preview.png"); err != nil { - t.Fatal(err) + 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) + + 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 + "/" + 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 + "_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) diff --git a/api/post.go b/api/post.go index fb9fdd1ef..f96320639 100644 --- a/api/post.go +++ b/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) } diff --git a/api/post_test.go b/api/post_test.go index e245366ca..cbba83af6 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -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) diff --git a/api/templates/post_subject.html b/api/templates/post_subject.html index 8ebc9550b..7d8941549 100644 --- a/api/templates/post_subject.html +++ b/api/templates/post_subject.html @@ -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}} diff --git a/api/user.go b/api/user.go index 18c5e863a..7035613ea 100644 --- a/api/user.go +++ b/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 } diff --git a/api/user_test.go b/api/user_test.go index e7a3f153a..8b95bdf55 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -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 - - 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 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) + } } } else { body := &bytes.Buffer{} diff --git a/config/config.json b/config/config.json index e6025ef51..085dd6de6 100644 --- a/config/config.json +++ b/config/config.json @@ -19,7 +19,9 @@ "InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6", "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", - "AnalyticsUrl": "" + "AnalyticsUrl": "", + "UseLocalStorage": true, + "StorageDirectory": "./data/" }, "SqlSettings": { "DriverName": "mysql", diff --git a/config/config_docker.json b/config/config_docker.json index 9be837072..062cdef65 100644 --- a/config/config_docker.json +++ b/config/config_docker.json @@ -19,7 +19,9 @@ "InviteSalt": "gxHVDcKUyP2y1eiyW8S8na1UYQAfq6J6", "PublicLinkSalt": "TO3pTyXIZzwHiwyZgGql7lM7DG3zeId4", "ResetSalt": "IPxFzSfnDFsNsRafZxz8NaYqFKhf9y2t", - "AnalyticsUrl": "" + "AnalyticsUrl": "", + "UseLocalStorage": true, + "StorageDirectory": "/mattermost/data/" }, "SqlSettings": { "DriverName": "mysql", diff --git a/model/client.go b/model/client.go index ad1e4b5f1..17bb898ca 100644 --- a/model/client.go +++ b/model/client.go @@ -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 { diff --git a/model/utils.go b/model/utils.go index 38592b984..093a54e38 100644 --- a/model/utils.go +++ b/model/utils.go @@ -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} diff --git a/utils/config.go b/utils/config.go index efa4b263a..e8fa9a477 100644 --- a/utils/config.go +++ b/utils/config.go @@ -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 { diff --git a/utils/urlencode.go b/utils/urlencode.go new file mode 100644 index 000000000..63a8f7880 --- /dev/null +++ b/utils/urlencode.go @@ -0,0 +1,19 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package utils + +import ( + "net/url" + "strings" +) + +func UrlEncode(str string) string { + strs := strings.Split(str, " ") + + for i, s := range strs { + strs[i] = url.QueryEscape(s) + } + + return strings.Join(strs, "%20") +} diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx new file mode 100644 index 000000000..b23b3213f --- /dev/null +++ b/web/react/components/access_history_modal.jsx @@ -0,0 +1,100 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var UserStore = require('../stores/user_store.jsx'); +var AsyncClient = require('../utils/async_client.jsx'); +var Utils = require('../utils/utils.jsx'); + +function getStateFromStoresForAudits() { + return { + audits: UserStore.getAudits() + }; +} + +module.exports = React.createClass({ + componentDidMount: function() { + UserStore.addAuditsChangeListener(this._onChange); + AsyncClient.getAudits(); + + var self = this; + $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) { + self.setState({ moreInfo: [] }); + }); + }, + componentWillUnmount: function() { + UserStore.removeAuditsChangeListener(this._onChange); + }, + _onChange: function() { + this.setState(getStateFromStoresForAudits()); + }, + handleMoreInfo: function(index) { + var newMoreInfo = this.state.moreInfo; + newMoreInfo[index] = true; + this.setState({ moreInfo: newMoreInfo }); + }, + getInitialState: function() { + var initialState = getStateFromStoresForAudits(); + initialState.moreInfo = []; + return initialState; + }, + render: function() { + var accessList = []; + var currentHistoryDate = null; + + for (var i = 0; i < this.state.audits.length; i++) { + var currentAudit = this.state.audits[i]; + var newHistoryDate = new Date(currentAudit.create_at); + var newDate = null; + + if (!currentHistoryDate || currentHistoryDate.toLocaleDateString() !== newHistoryDate.toLocaleDateString()) { + currentHistoryDate = newHistoryDate; + newDate = (<div> {currentHistoryDate.toDateString()} </div>); + } + + accessList[i] = ( + <div className="access-history__table"> + <div className="access__date">{newDate}</div> + <div className="access__report"> + <div className="report__time">{newHistoryDate.toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit'})}</div> + <div className="report__info"> + <div>{"IP: " + currentAudit.ip_address}</div> + { this.state.moreInfo[i] ? + <div> + <div>{"Session ID: " + currentAudit.session_id}</div> + <div>{"URL: " + currentAudit.action.replace("/api/v1", "")}</div> + </div> + : + <a href="#" onClick={this.handleMoreInfo.bind(this, i)}>More info</a> + } + </div> + {i < this.state.audits.length - 1 ? + <div className="divider-light"/> + : + null + } + </div> + </div> + ); + } + + return ( + <div> + <div className="modal fade" ref="modal" id="access-history" tabIndex="-1" role="dialog" aria-hidden="true"> + <div className="modal-dialog modal-lg"> + <div className="modal-content"> + <div className="modal-header"> + <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</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> + ); + } +}); diff --git a/web/react/components/activity_log_modal.jsx b/web/react/components/activity_log_modal.jsx new file mode 100644 index 000000000..d6f8f40eb --- /dev/null +++ b/web/react/components/activity_log_modal.jsx @@ -0,0 +1,116 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var UserStore = require('../stores/user_store.jsx'); +var Client = require('../utils/client.jsx'); +var AsyncClient = require('../utils/async_client.jsx'); + +function getStateFromStoresForSessions() { + return { + sessions: UserStore.getSessions(), + server_error: null, + client_error: null + }; +} + +module.exports = React.createClass({ + submitRevoke: function(altId) { + var self = this; + Client.revokeSession(altId, + function(data) { + AsyncClient.getSessions(); + }.bind(this), + function(err) { + state = getStateFromStoresForSessions(); + state.server_error = err; + this.setState(state); + }.bind(this) + ); + }, + componentDidMount: function() { + UserStore.addSessionsChangeListener(this._onChange); + AsyncClient.getSessions(); + + var self = this; + $(this.refs.modal.getDOMNode()).on('hidden.bs.modal', function(e) { + self.setState({ moreInfo: [] }); + }); + }, + componentWillUnmount: function() { + UserStore.removeSessionsChangeListener(this._onChange); + }, + _onChange: function() { + this.setState(getStateFromStoresForSessions()); + }, + handleMoreInfo: function(index) { + var newMoreInfo = this.state.moreInfo; + newMoreInfo[index] = true; + this.setState({ moreInfo: newMoreInfo }); + }, + getInitialState: function() { + var initialState = getStateFromStoresForSessions(); + initialState.moreInfo = []; + return initialState; + }, + render: function() { + var activityList = []; + var server_error = this.state.server_error ? this.state.server_error : null; + + for (var i = 0; i < this.state.sessions.length; i++) { + var currentSession = this.state.sessions[i]; + var lastAccessTime = new Date(currentSession.last_activity_at); + var firstAccessTime = new Date(currentSession.create_at); + var devicePicture = ""; + + if (currentSession.props.platform === "Windows") { + devicePicture = "fa fa-windows"; + } + else if (currentSession.props.platform === "Macintosh" || currentSession.props.platform === "iPhone") { + devicePicture = "fa fa-apple"; + } + + activityList[i] = ( + <div className="activity-log__table"> + <div className="activity-log__report"> + <div className="report__platform"><i className={devicePicture} />{currentSession.props.platform}</div> + <div className="report__info"> + <div>{"Last activity: " + lastAccessTime.toDateString() + ", " + lastAccessTime.toLocaleTimeString()}</div> + { this.state.moreInfo[i] ? + <div> + <div>{"First time active: " + firstAccessTime.toDateString() + ", " + lastAccessTime.toLocaleTimeString()}</div> + <div>{"OS: " + currentSession.props.os}</div> + <div>{"Browser: " + currentSession.props.browser}</div> + <div>{"Session ID: " + currentSession.alt_id}</div> + </div> + : + <a href="#" onClick={this.handleMoreInfo.bind(this, i)}>More info</a> + } + </div> + </div> + <div className="activity-log__action"><button onClick={this.submitRevoke.bind(this, currentSession.alt_id)} className="btn btn-primary">Logout</button></div> + </div> + ); + } + + return ( + <div> + <div className="modal fade" ref="modal" id="activity-log" tabIndex="-1" role="dialog" aria-hidden="true"> + <div className="modal-dialog modal-lg"> + <div className="modal-content"> + <div className="modal-header"> + <button type="button" className="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</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> + ); + } +}); diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index 6ed0f0b34..3f8e9ed2e 100644 --- a/web/react/components/create_comment.jsx +++ b/web/react/components/create_comment.jsx @@ -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; diff --git a/web/react/components/create_post.jsx b/web/react/components/create_post.jsx index d38a6798f..91d070958 100644 --- a/web/react/components/create_post.jsx +++ b/web/react/components/create_post.jsx @@ -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 diff --git a/web/react/components/edit_channel_modal.jsx b/web/react/components/edit_channel_modal.jsx index c0818959a..d055feacd 100644 --- a/web/react/components/edit_channel_modal.jsx +++ b/web/react/components/edit_channel_modal.jsx @@ -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> diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx index 17a1e2bc2..fdd12feec 100644 --- a/web/react/components/file_preview.jsx +++ b/web/react/components/file_preview.jsx @@ -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> diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx index 34c65c34f..500fabb0e 100644 --- a/web/react/components/navbar.jsx +++ b/web/react/components/navbar.jsx @@ -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; } } diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx index e72a2d001..e3586ecde 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -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" /> diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index d9678df30..7871f52b7 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -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( diff --git a/web/react/components/post_list.jsx b/web/react/components/post_list.jsx index c058455ba..8dc5013ca 100644 --- a/web/react/components/post_list.jsx +++ b/web/react/components/post_list.jsx @@ -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()) { diff --git a/web/react/components/post_right.jsx b/web/react/components/post_right.jsx index 581a1abe9..93f5d91b0 100644 --- a/web/react/components/post_right.jsx +++ b/web/react/components/post_right.jsx @@ -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() { diff --git a/web/react/components/sidebar_header.jsx b/web/react/components/sidebar_header.jsx index bab2897b6..7a7e92854 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -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> diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx index 59c97c309..ad890334e 100644 --- a/web/react/components/user_settings.jsx +++ b/web/react/components/user_settings.jsx @@ -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> diff --git a/web/react/components/user_settings_modal.jsx b/web/react/components/user_settings_modal.jsx index 1761e575a..421027244 100644 --- a/web/react/components/user_settings_modal.jsx +++ b/web/react/components/user_settings_modal.jsx @@ -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"> diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 2274f3f2e..c107de4d7 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -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} diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx index f70d60e3a..cc78df120 100644 --- a/web/react/pages/channel.jsx +++ b/web/react/pages/channel.jsx @@ -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') + ); + }; diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index 5280bfe08..0745fcdc3 100644 --- a/web/react/stores/post_store.jsx +++ b/web/react/stores/post_store.jsx @@ -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, draft) { + BrowserStore.setItem("draft_" + channel_id, draft); + }, + getDraft: function(channel_id) { + return BrowserStore.getItem("draft_" + channel_id); }, - storeDraft: function(channel_id, user_id, draft) { - BrowserStore.setItem("draft_" + channel_id + "_" + user_id, draft); + storeCommentDraft: function(parent_post_id, draft) { + BrowserStore.setItem("comment_draft_" + parent_post_id, draft); }, - getDraft: function(channel_id, user_id) { - return BrowserStore.getItem("draft_" + channel_id + "_" + user_id); + 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: } diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 187e3c4a3..2249da0d3 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -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, diff --git a/web/sass-files/sass/partials/_access-history.scss b/web/sass-files/sass/partials/_access-history.scss new file mode 100644 index 000000000..f54c9a122 --- /dev/null +++ b/web/sass-files/sass/partials/_access-history.scss @@ -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; + } +}
\ No newline at end of file diff --git a/web/sass-files/sass/partials/_activity-log.scss b/web/sass-files/sass/partials/_activity-log.scss new file mode 100644 index 000000000..36eb48750 --- /dev/null +++ b/web/sass-files/sass/partials/_activity-log.scss @@ -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; + } +}
\ No newline at end of file diff --git a/web/sass-files/sass/partials/_headers.scss b/web/sass-files/sass/partials/_headers.scss index adeaa70d7..eab4becac 100644 --- a/web/sass-files/sass/partials/_headers.scss +++ b/web/sass-files/sass/partials/_headers.scss @@ -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 { diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index 481ed63a5..df565d763 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -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; } } -}
\ No newline at end of file +} diff --git a/web/sass-files/sass/partials/_responsive.scss b/web/sass-files/sass/partials/_responsive.scss index a33d69378..d8a8fd982 100644 --- a/web/sass-files/sass/partials/_responsive.scss +++ b/web/sass-files/sass/partials/_responsive.scss @@ -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 { diff --git a/web/sass-files/sass/partials/_settings.scss b/web/sass-files/sass/partials/_settings.scss index e60bc290e..b8dc9e997 100644 --- a/web/sass-files/sass/partials/_settings.scss +++ b/web/sass-files/sass/partials/_settings.scss @@ -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; -} +}
\ No newline at end of file diff --git a/web/static/help/configure_links.html b/web/static/help/configure_links.html index be6490192..1c564e0d6 100644 --- a/web/static/help/configure_links.html +++ b/web/static/help/configure_links.html @@ -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> diff --git a/web/templates/channel.html b/web/templates/channel.html index eaf0f2563..8e856032d 100644 --- a/web/templates/channel.html +++ b/web/templates/channel.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> diff --git a/web/templates/signup_team_confirm.html b/web/templates/signup_team_confirm.html index 3a6134af3..2d27194bc 100644 --- a/web/templates/signup_team_confirm.html +++ b/web/templates/signup_team_confirm.html @@ -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> |