diff --git a/.gitignore b/.gitignore index c899ddd8f0..b8017c1988 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 55ab2986e8..fc2fb7646d 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 5c389c0560..e6c04d5419 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 diff --git a/Makefile b/Makefile index 589521c371..8793ba98ad 100644 --- a/Makefile +++ b/Makefile @@ -112,6 +112,8 @@ clean: rm -f web/static/js/bundle*.js rm -f web/static/css/styles.css + rm -rf data/* + rm -rf api/data/* rm -rf logs/* diff --git a/api/file.go b/api/file.go index 362cdf896b..82cee9d1e7 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 != nil { - c.Err = model.NewAppError("uploadFile", "Unable to upload file. ", err.Error()) + if err := writeFile(buf.Bytes(), path); err != nil { + c.Err = err return } - fileUrl := c.GetSiteURL() + "/api/v1/files/get/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + url.QueryEscape(files[i].Filename) + if model.IsFileExtImage(filepath.Ext(files[i].Filename)) { + imageNameList = append(imageNameList, uid+"/"+filename) + imageDataList = append(imageDataList, buf.Bytes()) + } + + encName := utils.UrlEncode(filename) + + fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName resStruct.Filenames = append(resStruct.Filenames, fileUrl) } @@ -127,13 +119,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) { func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, channelId, userId string) { go func() { - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - dest := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" for i, filename := range filenames { @@ -169,11 +154,8 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch return } - // Upload thumbnail to S3 - options := s3.Options{} - err = bucket.Put(dest+name+"_thumb.jpg", buf.Bytes(), "image/jpeg", s3.Private, options) - if err != nil { - l4g.Error("Unable to upload thumbnail to S3 channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) + if err := writeFile(buf.Bytes(), dest+name+"_thumb.jpg"); err != nil { + l4g.Error("Unable to upload thumbnail channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) return } }() @@ -188,19 +170,15 @@ func fireAndForgetHandleImages(filenames []string, fileData [][]byte, teamId, ch } buf := new(bytes.Buffer) - err = jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90}) - //err = png.Encode(buf, preview) + err = jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90}) if err != nil { l4g.Error("Unable to encode image as preview jpg channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) return } - // Upload preview to S3 - options := s3.Options{} - err = bucket.Put(dest+name+"_preview.jpg", buf.Bytes(), "image/jpeg", s3.Private, options) - if err != nil { - l4g.Error("Unable to upload preview to S3 channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) + if err := writeFile(buf.Bytes(), dest+name+"_preview.jpg"); err != nil { + l4g.Error("Unable to upload preview channelId=%v userId=%v filename=%v err=%v", channelId, userId, filename, err) return } }() @@ -215,8 +193,8 @@ type ImageGetResult struct { } func getFile(c *Context, w http.ResponseWriter, r *http.Request) { - if !utils.IsS3Configured() { - c.Err = model.NewAppError("getFile", "Unable to get file. Amazon S3 not configured. ", "") + if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + c.Err = model.NewAppError("getFile", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -247,13 +225,6 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId) - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - path := "" if len(teamId) == 26 { path = "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename @@ -262,7 +233,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { } fileData := make(chan []byte) - asyncGetFile(bucket, path, fileData) + asyncGetFile(path, fileData) if len(hash) > 0 && len(data) > 0 && len(teamId) == 26 { if !model.ComparePassword(hash, fmt.Sprintf("%v:%v", data, utils.Cfg.ServiceSettings.PublicLinkSalt)) { @@ -283,26 +254,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { f := <-fileData if f == nil { - var f2 []byte - tries := 0 - for { - time.Sleep(3000 * time.Millisecond) - tries++ - - asyncGetFile(bucket, path, fileData) - f2 = <-fileData - - if f2 != nil { - w.Header().Set("Cache-Control", "max-age=2592000, public") - w.Header().Set("Content-Length", strconv.Itoa(len(f2))) - w.Write(f2) - return - } else if tries >= 2 { - break - } - } - - c.Err = model.NewAppError("getFile", "Could not find file.", "url extenstion: "+path) + c.Err = model.NewAppError("getFile", "Could not find file.", "path="+path) c.Err.StatusCode = http.StatusNotFound return } @@ -312,10 +264,11 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(f) } -func asyncGetFile(bucket *s3.Bucket, path string, fileData chan []byte) { +func asyncGetFile(path string, fileData chan []byte) { go func() { - data, getErr := bucket.Get(path) + data, getErr := readFile(path) if getErr != nil { + l4g.Error(getErr) fileData <- nil } else { fileData <- data @@ -329,8 +282,8 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { c.Err.StatusCode = http.StatusForbidden } - if !utils.IsS3Configured() { - c.Err = model.NewAppError("getPublicLink", "Unable to get link. Amazon S3 not configured. ", "") + if !utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + c.Err = model.NewAppError("getPublicLink", "Unable to upload file. Amazon S3 not configured and local server storage turned off. ", "") c.Err.StatusCode = http.StatusNotImplemented return } @@ -344,15 +297,14 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { } matches := model.PartialUrlRegex.FindAllStringSubmatch(filename, -1) - if len(matches) == 0 || len(matches[0]) < 5 { + if len(matches) == 0 || len(matches[0]) < 4 { c.SetInvalidParam("getPublicLink", "filename") return } - getType := matches[0][1] - channelId := matches[0][2] - userId := matches[0][3] - filename = matches[0][4] + channelId := matches[0][1] + userId := matches[0][2] + filename = matches[0][3] cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId) @@ -363,7 +315,7 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { data := model.MapToJson(newProps) hash := model.HashPassword(fmt.Sprintf("%v:%v", data, utils.Cfg.ServiceSettings.PublicLinkSalt)) - url := fmt.Sprintf("%s/api/v1/files/%s/%s/%s/%s?d=%s&h=%s&t=%s", c.GetSiteURL(), getType, channelId, userId, filename, url.QueryEscape(data), url.QueryEscape(hash), c.Session.TeamId) + url := fmt.Sprintf("%s/api/v1/files/get/%s/%s/%s?d=%s&h=%s&t=%s", c.GetSiteURL(), channelId, userId, filename, url.QueryEscape(data), url.QueryEscape(hash), c.Session.TeamId) if !c.HasPermissionsToChannel(cchan, "getPublicLink") { return @@ -374,3 +326,78 @@ func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(rData))) } + +func writeFile(f []byte, path string) *model.AppError { + + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey + + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + + ext := filepath.Ext(path) + + var err error + if model.IsFileExtImage(ext) { + options := s3.Options{} + err = bucket.Put(path, f, model.GetImageMimeType(ext), s3.Private, options) + + } else { + options := s3.Options{} + err = bucket.Put(path, f, "binary/octet-stream", s3.Private, options) + } + + if err != nil { + return model.NewAppError("writeFile", "Encountered an error writing to S3", err.Error()) + } + } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { + if err := os.MkdirAll(filepath.Dir(utils.Cfg.ServiceSettings.StorageDirectory+path), 0774); err != nil { + return model.NewAppError("writeFile", "Encountered an error creating the directory for the new file", err.Error()) + } + + if err := ioutil.WriteFile(utils.Cfg.ServiceSettings.StorageDirectory+path, f, 0644); err != nil { + return model.NewAppError("writeFile", "Encountered an error writing to local server storage", err.Error()) + } + } else { + return model.NewAppError("writeFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") + } + + return nil +} + +func readFile(path string) ([]byte, *model.AppError) { + + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey + + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + + // try to get the file from S3 with some basic retry logic + tries := 0 + for { + tries++ + + f, err := bucket.Get(path) + + if f != nil { + return f, nil + } else if tries >= 3 { + return nil, model.NewAppError("readFile", "Unable to get file from S3", "path="+path+", err="+err.Error()) + } + time.Sleep(3000 * time.Millisecond) + } + } else if utils.Cfg.ServiceSettings.UseLocalStorage && len(utils.Cfg.ServiceSettings.StorageDirectory) > 0 { + if f, err := ioutil.ReadFile(utils.Cfg.ServiceSettings.StorageDirectory + path); err != nil { + return nil, model.NewAppError("readFile", "Encountered an error reading from local server storage", err.Error()) + } else { + return f, nil + } + } else { + return nil, model.NewAppError("readFile", "File storage not configured properly. Please configure for either S3 or local server file storage.", "") + } +} diff --git a/api/file_test.go b/api/file_test.go index 79ee03c778..566fd69d0e 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 + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - fileId := strings.Split(filenames[0], ".")[0] + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] - err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filenames[0]) - if err != nil { - t.Fatal(err) - } + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename) + if err != nil { + t.Fatal(err) + } - err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg") - if err != nil { - t.Fatal(err) - } + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg") + if err != nil { + t.Fatal(err) + } - err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.png") - if err != nil { - t.Fatal(err) + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg") + if err != nil { + t.Fatal(err) + } + } else { + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] + + path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } } } else { if _, downErr := Client.GetFile("/files/get/yxebdmbz5pgupx7q6ez88rw11a/n3btzxu9hbnapqk36iwaxkjxhc/junk.jpg", false); downErr.StatusCode != http.StatusNotImplemented { @@ -274,7 +325,7 @@ func TestGetPublicLink(t *testing.T) { channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) - if utils.IsS3Configured() { + if utils.IsS3Configured() || utils.Cfg.ServiceSettings.UseLocalStorage { body := &bytes.Buffer{} writer := multipart.NewWriter(body) @@ -350,26 +401,52 @@ func TestGetPublicLink(t *testing.T) { t.Fatal("should have errored, user not member of channel") } - // perform clean-up on s3 - var auth aws.Auth - auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId - auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + // perform clean-up on s3 + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - fileId := strings.Split(filenames[0], ".")[0] + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] - if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + filenames[0]); err != nil { - t.Fatal(err) - } + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename) + if err != nil { + t.Fatal(err) + } - if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + fileId + "_thumb.jpg"); err != nil { - t.Fatal(err) - } + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg") + if err != nil { + t.Fatal(err) + } - if err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + rpost1.Data.(*model.Post).UserId + "/" + fileId + "_preview.png"); err != nil { - t.Fatal(err) + err = bucket.Del("teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg") + if err != nil { + t.Fatal(err) + } + } else { + filenames := strings.Split(resp.Data.(*model.FileUploadResponse).Filenames[0], "/") + filename := filenames[len(filenames)-2] + "/" + filenames[len(filenames)-1] + fileId := strings.Split(filename, ".")[0] + + path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + filename + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_thumb.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } + + path = utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + team.Id + "/channels/" + channel1.Id + "/users/" + user1.Id + "/" + fileId + "_preview.jpg" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } } } else { data := make(map[string]string) diff --git a/api/post.go b/api/post.go index fb9fdd1eff..f963206390 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 e245366cac..cbba83af6e 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 8ebc9550b9..7d8941549a 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 18c5e863a2..7035613ea2 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 e7a3f153a2..8b95bdf552 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 + if utils.IsS3Configured() && !utils.Cfg.ServiceSettings.UseLocalStorage { + var auth aws.Auth + auth.AccessKey = utils.Cfg.AWSSettings.S3AccessKeyId + auth.SecretKey = utils.Cfg.AWSSettings.S3SecretAccessKey - s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) - bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) + s := s3.New(auth, aws.Regions[utils.Cfg.AWSSettings.S3Region]) + bucket := s.Bucket(utils.Cfg.AWSSettings.S3Bucket) - if err := bucket.Del("teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"); err != nil { - t.Fatal(err) + if err := bucket.Del("teams/" + user.TeamId + "/users/" + user.Id + "/profile.png"); err != nil { + t.Fatal(err) + } + } else { + path := utils.Cfg.ServiceSettings.StorageDirectory + "teams/" + user.TeamId + "/users/" + user.Id + "/profile.png" + if err := os.Remove(path); err != nil { + t.Fatal("Couldn't remove file at " + path) + } } } else { body := &bytes.Buffer{} diff --git a/config/config.json b/config/config.json index e6025ef517..085dd6de65 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 9be8370724..062cdef653 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 ad1e4b5f1f..17bb898ca1 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 38592b984b..093a54e38c 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 efa4b263a9..e8fa9a4778 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 0000000000..63a8f7880d --- /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 0000000000..b23b3213f7 --- /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 = (
{currentHistoryDate.toDateString()}
); + } + + accessList[i] = ( +
+
{newDate}
+
+
{newHistoryDate.toLocaleTimeString(navigator.language, {hour: '2-digit', minute:'2-digit'})}
+
+
{"IP: " + currentAudit.ip_address}
+ { this.state.moreInfo[i] ? +
+
{"Session ID: " + currentAudit.session_id}
+
{"URL: " + currentAudit.action.replace("/api/v1", "")}
+
+ : + More info + } +
+ {i < this.state.audits.length - 1 ? +
+ : + null + } +
+
+ ); + } + + return ( +
+ +
+ ); + } +}); diff --git a/web/react/components/activity_log_modal.jsx b/web/react/components/activity_log_modal.jsx new file mode 100644 index 0000000000..d6f8f40ebc --- /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] = ( +
+
+
{currentSession.props.platform}
+
+
{"Last activity: " + lastAccessTime.toDateString() + ", " + lastAccessTime.toLocaleTimeString()}
+ { this.state.moreInfo[i] ? +
+
{"First time active: " + firstAccessTime.toDateString() + ", " + lastAccessTime.toLocaleTimeString()}
+
{"OS: " + currentSession.props.os}
+
{"Browser: " + currentSession.props.browser}
+
{"Session ID: " + currentSession.alt_id}
+
+ : + More info + } +
+
+
+
+ ); + } + + return ( +
+ +
+ ); + } +}); diff --git a/web/react/components/create_comment.jsx b/web/react/components/create_comment.jsx index 6ed0f0b349..3f8e9ed2e5 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 d38a6798f9..91d0709585 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 c0818959ad..d055feacd0 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 ?

: null; + return (
diff --git a/web/react/components/file_preview.jsx b/web/react/components/file_preview.jsx index 17a1e2bc2b..fdd12feec4 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( -
+
); } else { previews.push( -
+
diff --git a/web/react/components/navbar.jsx b/web/react/components/navbar.jsx index 34c65c34fd..500fabb0e2 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 e72a2d001c..e3586ecded 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -83,7 +83,7 @@ module.exports = React.createClass({
: null } -
+
diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index d9678df30a..7871f52b7d 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") { $('').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 c058455bae..8dc5013cac 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 = (
-

Welcome

-

+

Beginning of {ui_name}

+

Welcome to {ui_name}!

{"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 = (

-

Welcome

-

+

Beginning of {ui_name}

+

{"This is the start of " + ui_name + ", a channel for conversations you’d prefer out of more focused channels."}
- Set a description

+ Set a description
); } else { var ui_type = channel.type === 'P' ? "private group" : "channel"; more_messages = (
-

Welcome

-

+

Beginning of {ui_name}

+

{ 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." }
- Set a description - Invite others to this {ui_type}

+ Set a description + Invite others to this {ui_type}
); } @@ -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 = ; + // 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 = ( + + ); 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 581a1abe9e..93f5d91b02 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( -
-
+
+
); images.push(filenames[i]); } else { postFiles.push( -
- +
+ @@ -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( -
-
+
+
); images.push(filenames[i]); } else { postFiles.push( -
- +
+ @@ -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 bab2897b6b..7a7e928549 100644 --- a/web/react/components/sidebar_header.jsx +++ b/web/react/components/sidebar_header.jsx @@ -115,7 +115,11 @@ module.exports = React.createClass({ return (
+ { me.last_picture_update ?
{ '@' + me.username}
{ teamDisplayName }
diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx index 59c97c3096..ad890334ea 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 ( -
-
- -

Sessions

-
-
-

Sessions

-
- { server_error } -
- - - - - - { - this.state.sessions.map(function(value, index) { - return ( - - - - - - - - - - ); - }, this) - } - -
IdPlatformOSBrowserCreatedLast ActivityRevoke
{ value.alt_id }{value.props.platform}{value.props.os}{value.props.browser}{ new Date(value.create_at).toLocaleString() }{ new Date(value.last_activity_at).toLocaleString() }
-
-
-
-
- ); - } -}); - -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 ( -
-
- -

Activity Log

-
-
-

Activity Log

-
-
- - - - - - - - - - - - { - this.state.audits.map(function(value, index) { - return ( - - - - - - - - ); - }, this) - } - -
TimeActionIP AddressSessionOther Info
{ new Date(value.create_at).toLocaleString() }{ value.action.replace("/api/v1", "") }{ value.ip_address }{ value.session_id }{ value.extra_info }
-
-
-
-
- ); - } -}); - 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({
); @@ -1225,23 +1094,6 @@ module.exports = React.createClass({
); - - /* Temporarily removing sessions and activity_log tabs - - } else if (this.props.activeTab === 'sessions') { - return ( -
- -
- ); - } else if (this.props.activeTab === 'activity_log') { - return ( -
- -
- ); - */ - } else if (this.props.activeTab === 'appearance') { return (
diff --git a/web/react/components/user_settings_modal.jsx b/web/react/components/user_settings_modal.jsx index 1761e575a5..4210272440 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 ( {loading} diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx index f70d60e3af..cc78df120a 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( + , + document.getElementById('access_history_modal') + ); + + React.render( + , + document.getElementById('activity_log_modal') + ); + }; diff --git a/web/react/stores/post_store.jsx b/web/react/stores/post_store.jsx index 5280bfe08c..0745fcdc3c 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, user_id, draft) { - BrowserStore.setItem("draft_" + channel_id + "_" + user_id, draft); + storeDraft: function(channel_id, draft) { + BrowserStore.setItem("draft_" + channel_id, draft); }, - getDraft: function(channel_id, user_id) { - return BrowserStore.getItem("draft_" + channel_id + "_" + user_id); + getDraft: function(channel_id) { + return BrowserStore.getItem("draft_" + channel_id); + }, + storeCommentDraft: function(parent_post_id, draft) { + BrowserStore.setItem("comment_draft_" + parent_post_id, draft); + }, + getCommentDraft: function(parent_post_id) { + return BrowserStore.getItem("comment_draft_" + parent_post_id); }, clearDraftUploads: function() { BrowserStore.actionOnItemsWithPrefix("draft_", function (key, value) { @@ -157,6 +174,14 @@ var PostStore = assign({}, EventEmitter.prototype, { BrowserStore.setItem(key, value); } }); + }, + clearCommentDraftUploads: function() { + BrowserStore.actionOnItemsWithPrefix("comment_draft_", function (key, value) { + if (value) { + value.uploadsInProgress = 0; + BrowserStore.setItem(key, value); + } + }); } }); @@ -186,6 +211,9 @@ PostStore.dispatchToken = AppDispatcher.register(function(payload) { case ActionTypes.RECIEVED_ADD_MENTION: PostStore.emitAddMention(action.id, action.username); break; + case ActionTypes.RECEIVED_ACTIVE_THREAD_CHANGED: + PostStore.emitActiveThreadChanged(action.root_id, action.parent_id); + break; default: } diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 187e3c4a39..2249da0d34 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 0000000000..f54c9a1226 --- /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 0000000000..36eb487503 --- /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 adeaa70d71..eab4becacd 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 481ed63a52..df565d7633 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 a33d693784..d8a8fd9828 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 e60bc290ec..b8dc9e9971 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 be6490192c..1c564e0d64 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 http:

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

Here's some links to get started:
-

  • Mattermost source code and install instructions
  • -
  • Mattermost Feature Request and Voting Site
  • -
  • Mattermost Issue Tracker for reporting bugs
  • +

    diff --git a/web/templates/channel.html b/web/templates/channel.html index eaf0f2563b..8e856032d5 100644 --- a/web/templates/channel.html +++ b/web/templates/channel.html @@ -45,6 +45,8 @@
    +
    +
    diff --git a/web/templates/signup_team_confirm.html b/web/templates/signup_team_confirm.html index 3a6134af3b..2d27194bc5 100644 --- a/web/templates/signup_team_confirm.html +++ b/web/templates/signup_team_confirm.html @@ -10,7 +10,7 @@