PLT-3105 Files table migration (#4068)

* Implemented initial changes for files table

* Removed *_benchmark_test.go files

* Re-implemented GetPublicFile and added support for old path

* Localization for files table

* Moved file system code into utils package

* Finished server-side changes and added initial upgrade script

* Added getPostFiles api

* Re-add Extension and HasPreviewImage fields to FileInfo

* Removed unused translation

* Fixed merge conflicts left over after permissions changes

* Forced FileInfo.extension to be lower case

* Changed FileUploadResponse to contain the FileInfos instead of FileIds

* Fixed permissions on getFile* calls

* Fixed notifications for file uploads

* Added initial version of client code for files changes

* Permanently added FileIds field to Post object and removed Post.HasFiles

* Updated PostStore.Update to be usable in more circumstances

* Re-added Filenames field and switched file migration to be entirely lazy-loaded

* Increased max listener count for FileStore

* Removed unused fileInfoCache

* Moved file system code back into api

* Removed duplicate test case

* Fixed unit test running on ports other than 8065

* Renamed HasPermissionToPostContext to HasPermissionToChannelByPostContext

* Refactored handleImages to make it more easily understandable

* Renamed getPostFiles to getFileInfosForPost

* Re-added pre-FileIds posts to analytics

* Changed files to be saved as their ids as opposed to id/filename.ext

* Renamed FileInfo.UserId to FileInfo.CreatorId

* Fixed detection of language in CodePreview

* Fixed switching between threads in the RHS not loading new files

* Add serverside protection against a rare bug where the client sends the same file twice for a single post

* Refactored the important parts of uploadFile api call into a function that can be called without a web context
Этот коммит содержится в:
Harrison Healey
2016-09-30 11:06:30 -04:00
коммит произвёл GitHub
родитель a2deeed597
Коммит 8a0e649f98
60 изменённых файлов: 3454 добавлений и 2062 удалений

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

@@ -33,7 +33,9 @@ type Routes struct {
Commands *mux.Router // 'api/v3/teams/{team_id:[A-Za-z0-9]+}/commands'
Hooks *mux.Router // 'api/v3/teams/{team_id:[A-Za-z0-9]+}/hooks'
Files *mux.Router // 'api/v3/teams/{team_id:[A-Za-z0-9]+}/files'
TeamFiles *mux.Router // 'api/v3/teams/{team_id:[A-Za-z0-9]+}/files'
Files *mux.Router // 'api/v3/files'
NeedFile *mux.Router // 'api/v3/files/{file_id:[A-Za-z0-9]+}'
OAuth *mux.Router // 'api/v3/oauth'
@@ -70,7 +72,9 @@ func InitApi() {
BaseRoutes.Posts = BaseRoutes.NeedChannel.PathPrefix("/posts").Subrouter()
BaseRoutes.NeedPost = BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter()
BaseRoutes.Commands = BaseRoutes.NeedTeam.PathPrefix("/commands").Subrouter()
BaseRoutes.Files = BaseRoutes.NeedTeam.PathPrefix("/files").Subrouter()
BaseRoutes.TeamFiles = BaseRoutes.NeedTeam.PathPrefix("/files").Subrouter()
BaseRoutes.Files = BaseRoutes.ApiRoot.PathPrefix("/files").Subrouter()
BaseRoutes.NeedFile = BaseRoutes.Files.PathPrefix("/{file_id:[A-Za-z0-9]+}").Subrouter()
BaseRoutes.Hooks = BaseRoutes.NeedTeam.PathPrefix("/hooks").Subrouter()
BaseRoutes.OAuth = BaseRoutes.ApiRoot.PathPrefix("/oauth").Subrouter()
BaseRoutes.Admin = BaseRoutes.ApiRoot.PathPrefix("/admin").Subrouter()

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

@@ -114,6 +114,42 @@ func HasPermissionToChannel(user *model.User, teamMember *model.TeamMember, chan
return HasPermissionToTeam(user, teamMember, permission)
}
func HasPermissionToChannelByPostContext(c *Context, postId string, permission *model.Permission) bool {
cmc := Srv.Store.Channel().GetMemberForPost(postId, c.Session.UserId)
var channelRoles []string
if cmcresult := <-cmc; cmcresult.Err == nil {
channelMember := cmcresult.Data.(*model.ChannelMember)
channelRoles = channelMember.GetRoles()
if CheckIfRolesGrantPermission(channelRoles, permission.Id) {
return true
}
}
cc := Srv.Store.Channel().GetForPost(postId)
if ccresult := <-cc; ccresult.Err == nil {
channel := ccresult.Data.(*model.Channel)
if teamMember := c.Session.GetTeamByTeamId(channel.TeamId); teamMember != nil {
roles := teamMember.GetRoles()
if CheckIfRolesGrantPermission(roles, permission.Id) {
return true
}
}
}
if HasPermissionToContext(c, permission) {
return true
}
c.Err = model.NewLocAppError("HasPermissionToChannelByPostContext", "api.context.permissions.app_error", nil, "userId="+c.Session.UserId+", "+"permission="+permission.Id+" channelRoles="+model.RoleIdsToString(channelRoles))
c.Err.StatusCode = http.StatusForbidden
return false
}
func HasPermissionToUser(c *Context, userId string) bool {
// You are the user (users autmaticly have permissions to themselves)
if c.Session.UserId == userId {

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

@@ -8,7 +8,6 @@ import (
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/utils"
"io"
"mime/multipart"
"os"
)
@@ -40,53 +39,31 @@ func NewAutoPostCreator(client *model.Client, channelid string) *AutoPostCreator
}
func (cfg *AutoPostCreator) UploadTestFile() ([]string, bool) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
filename := cfg.ImageFilenames[utils.RandIntFromRange(utils.Range{0, len(cfg.ImageFilenames) - 1})]
part, err := writer.CreateFormFile("files", filename)
if err != nil {
return nil, false
}
path := utils.FindDir("web/static/images")
file, err := os.Open(path + "/" + filename)
defer file.Close()
_, err = io.Copy(part, file)
data := &bytes.Buffer{}
_, err = io.Copy(data, file)
if err != nil {
return nil, false
}
field, err := writer.CreateFormField("channel_id")
if err != nil {
return nil, false
}
_, err = field.Write([]byte(cfg.channelid))
if err != nil {
return nil, false
}
err = writer.Close()
if err != nil {
return nil, false
}
resp, appErr := cfg.client.UploadPostAttachment(body.Bytes(), writer.FormDataContentType())
resp, appErr := cfg.client.UploadPostAttachment(data.Bytes(), cfg.channelid, filename)
if appErr != nil {
return nil, false
}
return resp.Data.(*model.FileUploadResponse).Filenames, true
return []string{resp.FileInfos[0].Id}, true
}
func (cfg *AutoPostCreator) CreateRandomPost() (*model.Post, bool) {
var filenames []string
var fileIds []string
if cfg.HasImage {
var err1 bool
filenames, err1 = cfg.UploadTestFile()
fileIds, err1 = cfg.UploadTestFile()
if err1 == false {
return nil, false
}
@@ -102,7 +79,7 @@ func (cfg *AutoPostCreator) CreateRandomPost() (*model.Post, bool) {
post := &model.Post{
ChannelId: cfg.channelid,
Message: postText,
Filenames: filenames}
FileIds: fileIds}
result, err2 := cfg.client.CreatePost(post)
if err2 != nil {
return nil, false

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

@@ -1,284 +0,0 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package api
import (
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
"testing"
)
const (
NUM_CHANNELS = 140
NUM_USERS = 40
)
func BenchmarkCreateChannel(b *testing.B) {
th := Setup().InitBasic()
channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
b.ResetTimer()
for i := 0; i < b.N; i++ {
channelCreator.CreateTestChannels(utils.Range{NUM_CHANNELS, NUM_CHANNELS})
}
}
func BenchmarkCreateDirectChannel(b *testing.B) {
th := Setup().InitBasic()
userCreator := NewAutoUserCreator(th.BasicClient, th.BasicTeam)
users, err := userCreator.CreateTestUsers(utils.Range{NUM_USERS, NUM_USERS})
if err == false {
b.Fatal("Could not create users")
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := 0; j < NUM_USERS; j++ {
th.BasicClient.CreateDirectChannel(users[j].Id)
}
}
}
func BenchmarkUpdateChannel(b *testing.B) {
th := Setup().InitBasic()
var (
NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
CHANNEL_HEADER_LEN = 50
)
channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
if valid == false {
b.Fatal("Unable to create test channels")
}
for i := range channels {
channels[i].Header = utils.RandString(CHANNEL_HEADER_LEN, utils.ALPHANUMERIC)
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := range channels {
if _, err := th.BasicClient.UpdateChannel(channels[j]); err != nil {
b.Fatal(err)
}
}
}
}
func BenchmarkGetChannels(b *testing.B) {
th := Setup().InitBasic()
var (
NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
)
channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
_, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
if valid == false {
b.Fatal("Unable to create test channels")
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
th.BasicClient.Must(th.BasicClient.GetChannels(""))
}
}
func BenchmarkGetMoreChannels(b *testing.B) {
th := Setup().InitBasic()
var (
NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
)
channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
_, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
if valid == false {
b.Fatal("Unable to create test channels")
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
th.BasicClient.Must(th.BasicClient.GetMoreChannels(""))
}
}
func BenchmarkJoinChannel(b *testing.B) {
th := Setup().InitBasic()
var (
NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
)
channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
if valid == false {
b.Fatal("Unable to create test channels")
}
// Secondary test user to join channels created by primary test user
user := &model.User{Email: "success+" + model.NewId() + "@simulator.amazonses.com", Nickname: "That Guy", Password: "pwd"}
user = th.BasicClient.Must(th.BasicClient.CreateUser(user, "")).Data.(*model.User)
LinkUserToTeam(user, th.BasicTeam)
store.Must(Srv.Store.User().VerifyEmail(user.Id))
th.BasicClient.Login(user.Email, "pwd")
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := range channels {
th.BasicClient.Must(th.BasicClient.JoinChannel(channels[j].Id))
}
}
}
func BenchmarkDeleteChannel(b *testing.B) {
th := Setup().InitBasic()
var (
NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
)
channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
if valid == false {
b.Fatal("Unable to create test channels")
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := range channels {
th.BasicClient.Must(th.BasicClient.DeleteChannel(channels[j].Id))
}
}
}
func BenchmarkGetChannelExtraInfo(b *testing.B) {
th := Setup().InitBasic()
var (
NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
)
channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
if valid == false {
b.Fatal("Unable to create test channels")
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := range channels {
th.BasicClient.Must(th.BasicClient.GetChannelExtraInfo(channels[j].Id, -1, ""))
}
}
}
func BenchmarkAddChannelMember(b *testing.B) {
th := Setup().InitBasic()
var (
NUM_USERS = 100
NUM_USERS_RANGE = utils.Range{NUM_USERS, NUM_USERS}
)
channel := &model.Channel{DisplayName: "Test Channel", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: th.BasicTeam.Id}
channel = th.BasicClient.Must(th.BasicClient.CreateChannel(channel)).Data.(*model.Channel)
userCreator := NewAutoUserCreator(th.BasicClient, th.BasicTeam)
users, valid := userCreator.CreateTestUsers(NUM_USERS_RANGE)
if valid == false {
b.Fatal("Unable to create test users")
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := range users {
if _, err := th.BasicClient.AddChannelMember(channel.Id, users[j].Id); err != nil {
b.Fatal(err)
}
}
}
}
// Is this benchmark failing? Raise your file ulimit! 2048 worked for me.
func BenchmarkRemoveChannelMember(b *testing.B) {
th := Setup().InitBasic()
var (
NUM_USERS = 140
NUM_USERS_RANGE = utils.Range{NUM_USERS, NUM_USERS}
)
channel := &model.Channel{DisplayName: "Test Channel", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: th.BasicTeam.Id}
channel = th.BasicClient.Must(th.BasicClient.CreateChannel(channel)).Data.(*model.Channel)
userCreator := NewAutoUserCreator(th.BasicClient, th.BasicTeam)
users, valid := userCreator.CreateTestUsers(NUM_USERS_RANGE)
if valid == false {
b.Fatal("Unable to create test users")
}
for i := range users {
if _, err := th.BasicClient.AddChannelMember(channel.Id, users[i].Id); err != nil {
b.Fatal(err)
}
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := range users {
if _, err := th.BasicClient.RemoveChannelMember(channel.Id, users[j].Id); err != nil {
b.Fatal(err)
}
}
}
}
func BenchmarkUpdateNotifyProps(b *testing.B) {
th := Setup().InitBasic()
var (
NUM_CHANNELS_RANGE = utils.Range{NUM_CHANNELS, NUM_CHANNELS}
)
channelCreator := NewAutoChannelCreator(th.BasicClient, th.BasicTeam)
channels, valid := channelCreator.CreateTestChannels(NUM_CHANNELS_RANGE)
if valid == false {
b.Fatal("Unable to create test channels")
}
data := make([]map[string]string, len(channels))
for i := range data {
newmap := map[string]string{
"channel_id": channels[i].Id,
"user_id": th.BasicUser.Id,
"desktop": model.CHANNEL_NOTIFY_MENTION,
"mark_unread": model.CHANNEL_MARK_UNREAD_MENTION,
}
data[i] = newmap
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := range channels {
th.BasicClient.Must(th.BasicClient.UpdateNotifyProps(data[j]))
}
}
}

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

@@ -16,6 +16,7 @@ import (
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -57,17 +58,19 @@ const (
MaxImageSize = 6048 * 4032 // 24 megapixels, roughly 36MB as a raw image
)
var fileInfoCache *utils.Cache = utils.NewLru(1000)
func InitFile() {
l4g.Debug(utils.T("api.file.init.debug"))
BaseRoutes.Files.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST")
BaseRoutes.Files.Handle("/get/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiUserRequiredTrustRequester(getFile)).Methods("GET")
BaseRoutes.Files.Handle("/get_info/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiUserRequired(getFileInfo)).Methods("GET")
BaseRoutes.Files.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("POST")
BaseRoutes.TeamFiles.Handle("/upload", ApiUserRequired(uploadFile)).Methods("POST")
BaseRoutes.Public.Handle("/files/get/{team_id:[A-Za-z0-9]+}/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandlerTrustRequesterIndependent(getPublicFile)).Methods("GET")
BaseRoutes.NeedFile.Handle("/get", ApiUserRequiredTrustRequester(getFile)).Methods("GET")
BaseRoutes.NeedFile.Handle("/get_thumbnail", ApiUserRequiredTrustRequester(getFileThumbnail)).Methods("GET")
BaseRoutes.NeedFile.Handle("/get_preview", ApiUserRequiredTrustRequester(getFilePreview)).Methods("GET")
BaseRoutes.NeedFile.Handle("/get_info", ApiUserRequired(getFileInfo)).Methods("GET")
BaseRoutes.NeedFile.Handle("/get_public_link", ApiUserRequired(getPublicLink)).Methods("GET")
BaseRoutes.Public.Handle("/files/{file_id:[A-Za-z0-9]+}/get", ApiAppHandlerTrustRequesterIndependent(getPublicFile)).Methods("GET")
BaseRoutes.Public.Handle("/files/get/{team_id:[A-Za-z0-9]+}/{channel_id:[A-Za-z0-9]+}/{user_id:[A-Za-z0-9]+}/{filename:([A-Za-z0-9]+/)?.+(\\.[A-Za-z0-9]{3,})?}", ApiAppHandlerTrustRequesterIndependent(getPublicFileOld)).Methods("GET")
}
func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -83,8 +86,7 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
err := r.ParseMultipartForm(*utils.Cfg.FileSettings.MaxFileSize)
if err != nil {
if err := r.ParseMultipartForm(*utils.Cfg.FileSettings.MaxFileSize); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -92,7 +94,6 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
m := r.MultipartForm
props := m.Value
if len(props["channel_id"]) == 0 {
c.SetInvalidParam("uploadFile", "channel_id")
return
@@ -103,91 +104,108 @@ func uploadFile(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
files := m.File["files"]
resStruct := &model.FileUploadResponse{
Filenames: []string{},
ClientIds: []string{},
}
imageNameList := []string{}
imageDataList := [][]byte{}
if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_UPLOAD_FILE) {
return
}
for i := range files {
file, err := files[i].Open()
resStruct := &model.FileUploadResponse{
FileInfos: []*model.FileInfo{},
ClientIds: []string{},
}
previewPathList := []string{}
thumbnailPathList := []string{}
imageDataList := [][]byte{}
for i, fileHeader := range m.File["files"] {
file, fileErr := fileHeader.Open()
defer file.Close()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if fileErr != nil {
http.Error(w, fileErr.Error(), http.StatusInternalServerError)
return
}
buf := bytes.NewBuffer(nil)
io.Copy(buf, file)
data := buf.Bytes()
filename := filepath.Base(files[i].Filename)
uid := model.NewId()
if model.IsFileExtImage(filepath.Ext(files[i].Filename)) {
imageNameList = append(imageNameList, uid+"/"+filename)
imageDataList = append(imageDataList, buf.Bytes())
// Decode image config first to check dimensions before loading the whole thing into memory later on
config, _, err := image.DecodeConfig(bytes.NewReader(buf.Bytes()))
if err != nil {
c.Err = model.NewLocAppError("uploadFile", "api.file.upload_file.image.app_error", nil, err.Error())
return
} else if config.Width*config.Height > MaxImageSize {
c.Err = model.NewLocAppError("uploadFile", "api.file.upload_file.large_image.app_error", nil, c.T("api.file.file_upload.exceeds"))
return
}
}
path := "teams/" + c.TeamId + "/channels/" + channelId + "/users/" + c.Session.UserId + "/" + uid + "/" + filename
if err := WriteFile(buf.Bytes(), path); err != nil {
info, err := doUploadFile(c.TeamId, channelId, c.Session.UserId, fileHeader.Filename, data)
if err != nil {
c.Err = err
return
}
encName := utils.UrlEncode(filename)
if info.PreviewPath != "" || info.ThumbnailPath != "" {
previewPathList = append(previewPathList, info.PreviewPath)
thumbnailPathList = append(thumbnailPathList, info.ThumbnailPath)
imageDataList = append(imageDataList, data)
}
fileUrl := "/" + channelId + "/" + c.Session.UserId + "/" + uid + "/" + encName
resStruct.Filenames = append(resStruct.Filenames, fileUrl)
resStruct.FileInfos = append(resStruct.FileInfos, info)
if len(m.Value["client_ids"]) > 0 {
resStruct.ClientIds = append(resStruct.ClientIds, m.Value["client_ids"][i])
}
}
for _, clientId := range props["client_ids"] {
resStruct.ClientIds = append(resStruct.ClientIds, clientId)
}
go handleImages(imageNameList, imageDataList, c.TeamId, channelId, c.Session.UserId)
handleImages(previewPathList, thumbnailPathList, imageDataList)
w.Write([]byte(resStruct.ToJson()))
}
func handleImages(filenames []string, fileData [][]byte, teamId, channelId, userId string) {
dest := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/"
func doUploadFile(teamId string, channelId string, userId string, rawFilename string, data []byte) (*model.FileInfo, *model.AppError) {
filename := filepath.Base(rawFilename)
for i, filename := range filenames {
name := filename[:strings.LastIndex(filename, ".")]
info, err := model.GetInfoForBytes(filename, data)
if err != nil {
err.StatusCode = http.StatusBadRequest
return nil, err
}
info.Id = model.NewId()
info.CreatorId = userId
pathPrefix := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + info.Id + "/"
info.Path = pathPrefix + filename
if info.IsImage() {
// Check dimensions before loading the whole thing into memory later on
if info.Width*info.Height > MaxImageSize {
err := model.NewLocAppError("uploadFile", "api.file.upload_file.large_image.app_error", nil, "")
err.StatusCode = http.StatusBadRequest
return nil, err
}
nameWithoutExtension := filename[:strings.LastIndex(filename, ".")]
info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg"
info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg"
}
if err := WriteFile(data, info.Path); err != nil {
return nil, err
}
if result := <-Srv.Store.FileInfo().Save(info); result.Err != nil {
return nil, result.Err
}
return info, nil
}
func handleImages(previewPathList []string, thumbnailPathList []string, fileData [][]byte) {
for i := range fileData {
go func() {
// Decode image bytes into Image object
img, imgType, err := image.Decode(bytes.NewReader(fileData[i]))
if err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.decode.error"), channelId, userId, filename, err)
l4g.Error(utils.T("api.file.handle_images_forget.decode.error"), err)
return
}
width := img.Bounds().Dx()
height := img.Bounds().Dy()
// Get the image's orientation and ignore any errors since not all images will have orientation data
orientation, _ := getImageOrientation(fileData[i])
// Fill in the background of a potentially-transparent png file as white
if imgType == "png" {
dst := image.NewRGBA(img.Bounds())
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
@@ -195,6 +213,9 @@ func handleImages(filenames []string, fileData [][]byte, teamId, channelId, user
img = dst
}
// Flip the image to be upright
orientation, _ := getImageOrientation(fileData[i])
switch orientation {
case UprightMirrored:
img = imaging.FlipH(img)
@@ -212,57 +233,8 @@ func handleImages(filenames []string, fileData [][]byte, teamId, channelId, user
img = imaging.Rotate90(img)
}
// Create thumbnail
go func() {
thumbWidth := float64(utils.Cfg.FileSettings.ThumbnailWidth)
thumbHeight := float64(utils.Cfg.FileSettings.ThumbnailHeight)
imgWidth := float64(width)
imgHeight := float64(height)
var thumbnail image.Image
if imgHeight < thumbHeight && imgWidth < thumbWidth {
thumbnail = img
} else if imgHeight/imgWidth < thumbHeight/thumbWidth {
thumbnail = imaging.Resize(img, 0, utils.Cfg.FileSettings.ThumbnailHeight, imaging.Lanczos)
} else {
thumbnail = imaging.Resize(img, utils.Cfg.FileSettings.ThumbnailWidth, 0, imaging.Lanczos)
}
buf := new(bytes.Buffer)
err = jpeg.Encode(buf, thumbnail, &jpeg.Options{Quality: 90})
if err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.encode_jpeg.error"), channelId, userId, filename, err)
return
}
if err := WriteFile(buf.Bytes(), dest+name+"_thumb.jpg"); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.upload_thumb.error"), channelId, userId, filename, err)
return
}
}()
// Create preview
go func() {
var preview image.Image
if width > int(utils.Cfg.FileSettings.PreviewWidth) {
preview = imaging.Resize(img, utils.Cfg.FileSettings.PreviewWidth, utils.Cfg.FileSettings.PreviewHeight, imaging.Lanczos)
} else {
preview = img
}
buf := new(bytes.Buffer)
err = jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90})
if err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.encode_preview.error"), channelId, userId, filename, err)
return
}
if err := WriteFile(buf.Bytes(), dest+name+"_preview.jpg"); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.upload_preview.error"), channelId, userId, filename, err)
return
}
}()
go generateThumbnailImage(img, thumbnailPathList[i], width, height)
go generatePreviewImage(img, previewPathList[i], width)
}()
}
}
@@ -284,88 +256,214 @@ func getImageOrientation(imageData []byte) (int, error) {
}
}
type ImageGetResult struct {
Error error
ImageData []byte
func generateThumbnailImage(img image.Image, thumbnailPath string, width int, height int) {
thumbWidth := float64(utils.Cfg.FileSettings.ThumbnailWidth)
thumbHeight := float64(utils.Cfg.FileSettings.ThumbnailHeight)
imgWidth := float64(width)
imgHeight := float64(height)
var thumbnail image.Image
if imgHeight < thumbHeight && imgWidth < thumbWidth {
thumbnail = img
} else if imgHeight/imgWidth < thumbHeight/thumbWidth {
thumbnail = imaging.Resize(img, 0, utils.Cfg.FileSettings.ThumbnailHeight, imaging.Lanczos)
} else {
thumbnail = imaging.Resize(img, utils.Cfg.FileSettings.ThumbnailWidth, 0, imaging.Lanczos)
}
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, thumbnail, &jpeg.Options{Quality: 90}); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.encode_jpeg.error"), thumbnailPath, err)
return
}
if err := WriteFile(buf.Bytes(), thumbnailPath); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.upload_thumb.error"), thumbnailPath, err)
return
}
}
func generatePreviewImage(img image.Image, previewPath string, width int) {
var preview image.Image
if width > int(utils.Cfg.FileSettings.PreviewWidth) {
preview = imaging.Resize(img, utils.Cfg.FileSettings.PreviewWidth, utils.Cfg.FileSettings.PreviewHeight, imaging.Lanczos)
} else {
preview = img
}
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90}); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.encode_preview.error"), previewPath, err)
return
}
if err := WriteFile(buf.Bytes(), previewPath); err != nil {
l4g.Error(utils.T("api.file.handle_images_forget.upload_preview.error"), previewPath, err)
return
}
}
func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
info, err := getFileInfoForRequest(c, r, true)
if err != nil {
c.Err = err
return
}
if data, err := ReadFile(info.Path); err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
} else if err := writeFileResponse(info.Name, data, w, r); err != nil {
c.Err = err
return
}
}
func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) {
info, err := getFileInfoForRequest(c, r, true)
if err != nil {
c.Err = err
return
}
if info.ThumbnailPath == "" {
c.Err = model.NewLocAppError("getFileThumbnail", "api.file.get_file_thumbnail.no_thumbnail.app_error", nil, "file_id="+info.Id)
c.Err.StatusCode = http.StatusBadRequest
return
}
if data, err := ReadFile(info.ThumbnailPath); err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
} else if err := writeFileResponse(info.Name, data, w, r); err != nil {
c.Err = err
return
}
}
func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) {
info, err := getFileInfoForRequest(c, r, true)
if err != nil {
c.Err = err
return
}
if info.PreviewPath == "" {
c.Err = model.NewLocAppError("getFilePreview", "api.file.get_file_preview.no_preview.app_error", nil, "file_id="+info.Id)
c.Err.StatusCode = http.StatusBadRequest
return
}
if data, err := ReadFile(info.PreviewPath); err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
} else if err := writeFileResponse(info.Name, data, w, r); err != nil {
c.Err = err
return
}
}
func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
if len(utils.Cfg.FileSettings.DriverName) == 0 {
c.Err = model.NewLocAppError("uploadFile", "api.file.upload_file.storage.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented
info, err := getFileInfoForRequest(c, r, true)
if err != nil {
c.Err = err
return
}
params := mux.Vars(r)
channelId := params["channel_id"]
if len(channelId) != 26 {
c.SetInvalidParam("getFileInfo", "channel_id")
return
}
userId := params["user_id"]
if len(userId) != 26 {
c.SetInvalidParam("getFileInfo", "user_id")
return
}
filename := params["filename"]
if len(filename) == 0 {
c.SetInvalidParam("getFileInfo", "filename")
return
}
if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) {
return
}
path := "teams/" + c.TeamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
var info *model.FileInfo
if cached, ok := fileInfoCache.Get(path); ok {
info = cached.(*model.FileInfo)
} else {
fileData := make(chan []byte)
go readFile(path, fileData)
newInfo, err := model.GetInfoForBytes(filename, <-fileData)
if err != nil {
c.Err = err
return
} else {
fileInfoCache.Add(path, newInfo)
info = newInfo
}
}
w.Header().Set("Cache-Control", "max-age=2592000, public")
w.Write([]byte(info.ToJson()))
}
func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
teamId := c.TeamId
channelId := params["channel_id"]
userId := params["user_id"]
filename := params["filename"]
if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) {
func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
if !utils.Cfg.FileSettings.EnablePublicLink {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_disabled.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented
return
}
if err, bytes := getFileData(teamId, channelId, userId, filename); err != nil {
info, err := getFileInfoForRequest(c, r, false)
if err != nil {
c.Err = err
return
} else if err := writeFileResponse(filename, bytes, w, r); err != nil {
}
hash := r.URL.Query().Get("h")
if len(hash) > 0 {
correctHash := generatePublicLinkHash(info.Id, *utils.Cfg.FileSettings.PublicLinkSalt)
if hash != correctHash {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "")
c.Err.StatusCode = http.StatusBadRequest
return
}
} else {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "")
c.Err.StatusCode = http.StatusBadRequest
return
}
if data, err := ReadFile(info.Path); err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
} else if err := writeFileResponse(info.Name, data, w, r); err != nil {
c.Err = err
return
}
}
func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
func getFileInfoForRequest(c *Context, r *http.Request, requireFileVisible bool) (*model.FileInfo, *model.AppError) {
if len(utils.Cfg.FileSettings.DriverName) == 0 {
err := model.NewLocAppError("getFileInfoForRequest", "api.file.get_file_info_for_request.storage.app_error", nil, "")
err.StatusCode = http.StatusNotImplemented
return nil, err
}
params := mux.Vars(r)
fileId := params["file_id"]
if len(fileId) != 26 {
return nil, NewInvalidParamError("getFileInfoForRequest", "file_id")
}
var info *model.FileInfo
if result := <-Srv.Store.FileInfo().Get(fileId); result.Err != nil {
return nil, result.Err
} else {
info = result.Data.(*model.FileInfo)
}
// only let users access files visible in a channel, unless they're the one who uploaded the file
if info.CreatorId != c.Session.UserId {
if len(info.PostId) == 0 {
err := model.NewLocAppError("getFileInfoForRequest", "api.file.get_file_info_for_request.no_post.app_error", nil, "file_id="+fileId)
err.StatusCode = http.StatusBadRequest
return nil, err
}
if requireFileVisible {
if !HasPermissionToChannelByPostContext(c, info.PostId, model.PERMISSION_READ_CHANNEL) {
return nil, c.Err
}
}
}
return info, nil
}
func getPublicFileOld(c *Context, w http.ResponseWriter, r *http.Request) {
if len(utils.Cfg.FileSettings.DriverName) == 0 {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_public_file_old.storage.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented
return
} else if !utils.Cfg.FileSettings.EnablePublicLink {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_disabled.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented
return
}
params := mux.Vars(r)
teamId := params["team_id"]
@@ -375,12 +473,6 @@ func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
hash := r.URL.Query().Get("h")
if !utils.Cfg.FileSettings.EnablePublicLink {
c.Err = model.NewLocAppError("getPublicFile", "api.file.get_file.public_disabled.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented
return
}
if len(hash) > 0 {
correctHash := generatePublicLinkHash(filename, *utils.Cfg.FileSettings.PublicLinkSalt)
@@ -395,49 +487,28 @@ func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if err, bytes := getFileData(teamId, channelId, userId, filename); err != nil {
c.Err = err
return
} else if err := writeFileResponse(filename, bytes, w, r); err != nil {
c.Err = err
return
}
}
func getFileData(teamId string, channelId string, userId string, filename string) (*model.AppError, []byte) {
if len(utils.Cfg.FileSettings.DriverName) == 0 {
err := model.NewLocAppError("getFileData", "api.file.upload_file.storage.app_error", nil, "")
err.StatusCode = http.StatusNotImplemented
return err, nil
}
if len(teamId) != 26 {
return NewInvalidParamError("getFileData", "team_id"), nil
}
if len(channelId) != 26 {
return NewInvalidParamError("getFileData", "channel_id"), nil
}
if len(userId) != 26 {
return NewInvalidParamError("getFileData", "user_id"), nil
}
if len(filename) == 0 {
return NewInvalidParamError("getFileData", "filename"), nil
}
path := "teams/" + teamId + "/channels/" + channelId + "/users/" + userId + "/" + filename
fileChan := make(chan []byte)
go readFile(path, fileChan)
if bytes := <-fileChan; bytes == nil {
err := model.NewLocAppError("writeFileResponse", "api.file.get_file.not_found.app_error", nil, "path="+path)
err.StatusCode = http.StatusNotFound
return err, nil
var info *model.FileInfo
if result := <-Srv.Store.FileInfo().GetByPath(path); result.Err != nil {
c.Err = result.Err
return
} else {
return nil, bytes
info = result.Data.(*model.FileInfo)
}
if len(info.PostId) == 0 {
c.Err = model.NewLocAppError("getPublicFileOld", "api.file.get_public_file_old.no_post.app_error", nil, "file_id="+info.Id)
c.Err.StatusCode = http.StatusBadRequest
return
}
if data, err := ReadFile(info.Path); err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
} else if err := writeFileResponse(info.Name, data, w, r); err != nil {
c.Err = err
return
}
}
@@ -450,9 +521,7 @@ func writeFileResponse(filename string, bytes []byte, w http.ResponseWriter, r *
ua := user_agent.New(r.UserAgent())
bname, _ := ua.Browser()
parts := strings.Split(filename, "/")
filePart := strings.Split(parts[len(parts)-1], "?")[0]
w.Header().Set("Content-Disposition", "attachment;filename=\""+filePart+"\"")
w.Header().Set("Content-Disposition", "attachment;filename=\""+filename+"\"")
if bname == "Edge" || bname == "Internet Explorer" || bname == "Safari" {
w.Header().Set("Content-Type", "application/octet-stream")
@@ -467,71 +536,183 @@ func writeFileResponse(filename string, bytes []byte, w http.ResponseWriter, r *
return nil
}
func readFile(path string, fileData chan []byte) {
data, getErr := ReadFile(path)
if getErr != nil {
l4g.Error(getErr)
fileData <- nil
} else {
fileData <- data
}
}
func getPublicLink(c *Context, w http.ResponseWriter, r *http.Request) {
if len(utils.Cfg.FileSettings.DriverName) == 0 {
c.Err = model.NewLocAppError("uploadFile", "api.file.upload_file.storage.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented
return
}
if !utils.Cfg.FileSettings.EnablePublicLink {
c.Err = model.NewLocAppError("getPublicLink", "api.file.get_public_link.disabled.app_error", nil, "")
c.Err.StatusCode = http.StatusNotImplemented
return
}
props := model.MapFromJson(r.Body)
filename := props["filename"]
if len(filename) == 0 {
c.SetInvalidParam("getPublicLink", "filename")
info, err := getFileInfoForRequest(c, r, true)
if err != nil {
c.Err = err
return
}
matches := model.PartialUrlRegex.FindAllStringSubmatch(filename, -1)
if len(matches) == 0 || len(matches[0]) < 4 {
c.SetInvalidParam("getPublicLink", "filename")
if len(info.PostId) == 0 {
c.Err = model.NewLocAppError("getPublicLink", "api.file.get_public_link.no_post.app_error", nil, "file_id="+info.Id)
c.Err.StatusCode = http.StatusBadRequest
return
}
channelId := matches[0][1]
userId := matches[0][2]
filename = matches[0][3]
if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_GET_PUBLIC_LINK) {
return
}
url := generatePublicLink(c.GetSiteURL(), c.TeamId, channelId, userId, filename)
w.Write([]byte(model.StringToJson(url)))
w.Write([]byte(model.StringToJson(generatePublicLink(c.GetSiteURL(), info))))
}
func generatePublicLink(siteURL, teamId, channelId, userId, filename string) string {
hash := generatePublicLinkHash(filename, *utils.Cfg.FileSettings.PublicLinkSalt)
return fmt.Sprintf("%s%s/public/files/get/%s/%s/%s/%s?h=%s", siteURL, model.API_URL_SUFFIX, teamId, channelId, userId, filename, hash)
func generatePublicLink(siteURL string, info *model.FileInfo) string {
hash := generatePublicLinkHash(info.Id, *utils.Cfg.FileSettings.PublicLinkSalt)
return fmt.Sprintf("%s%s/public/files/%v/get?h=%s", siteURL, model.API_URL_SUFFIX, info.Id, hash)
}
func generatePublicLinkHash(filename, salt string) string {
func generatePublicLinkHash(fileId, salt string) string {
hash := sha256.New()
hash.Write([]byte(salt))
hash.Write([]byte(filename))
hash.Write([]byte(fileId))
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
}
func WriteFile(f []byte, path string) *model.AppError {
// Creates and stores FileInfos for a post created before the FileInfos table existed.
func migrateFilenamesToFileInfos(post *model.Post) []*model.FileInfo {
if len(post.Filenames) == 0 {
l4g.Warn(utils.T("api.file.migrate_filenames_to_file_infos.no_filenames.warn"), post.Id)
return []*model.FileInfo{}
}
cchan := Srv.Store.Channel().Get(post.ChannelId)
// There's a weird bug that rarely happens where a post ends up with duplicate Filenames so remove those
filenames := utils.RemoveDuplicatesFromStringArray(post.Filenames)
var channel *model.Channel
if result := <-cchan; result.Err != nil {
l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.channel.app_error"), post.Id, post.ChannelId, result.Err)
return []*model.FileInfo{}
} else {
channel = result.Data.(*model.Channel)
}
// Find the team that was used to make this post since its part of the file path that isn't saved in the Filename
var teamId string
if channel.TeamId == "" {
// This post was made in a cross-team DM channel so we need to find where its files were saved
teamId = findTeamIdForFilename(post, filenames[0])
} else {
teamId = channel.TeamId
}
// Create FileInfo objects for this post
infos := make([]*model.FileInfo, 0, len(filenames))
fileIds := make([]string, 0, len(filenames))
if teamId == "" {
l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.team_id.error"), post.Id, filenames)
} else {
for _, filename := range filenames {
info := getInfoForFilename(post, teamId, filename)
if info == nil {
continue
}
if result := <-Srv.Store.FileInfo().Save(info); result.Err != nil {
l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.save_file_info.app_error"), post.Id, info.Id, filename, result.Err)
continue
}
fileIds = append(fileIds, info.Id)
infos = append(infos, info)
}
}
// Copy and save the updated post
newPost := &model.Post{}
*newPost = *post
newPost.Filenames = []string{}
newPost.FileIds = fileIds
// Update Posts to clear Filenames and set FileIds
if result := <-Srv.Store.Post().Update(newPost, post); result.Err != nil {
l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.save_post.app_error"), post.Id, newPost.FileIds, post.Filenames, result.Err)
return []*model.FileInfo{}
} else {
return infos
}
}
func findTeamIdForFilename(post *model.Post, filename string) string {
split := strings.SplitN(filename, "/", 5)
id := split[3]
name, _ := url.QueryUnescape(split[4])
// This post is in a direct channel so we need to figure out what team the files are stored under.
if result := <-Srv.Store.Team().GetTeamsByUserId(post.UserId); result.Err != nil {
l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.teams.app_error"), post.Id, result.Err)
} else if teams := result.Data.([]*model.Team); len(teams) == 1 {
// The user has only one team so the post must've been sent from it
return teams[0].Id
} else {
for _, team := range teams {
path := fmt.Sprintf("teams/%s/channels/%s/users/%s/%s/%s", team.Id, post.ChannelId, post.UserId, id, name)
if _, err := ReadFile(path); err == nil {
// Found the team that this file was posted from
return team.Id
}
}
}
return ""
}
func getInfoForFilename(post *model.Post, teamId string, filename string) *model.FileInfo {
// Find the path from the Filename of the form /{channelId}/{userId}/{uid}/{nameWithExtension}
split := strings.SplitN(filename, "/", 5)
if len(split) < 5 {
l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.unexpected_filename.error"), post.Id, filename)
return nil
}
channelId := split[1]
userId := split[2]
oldId := split[3]
name, _ := url.QueryUnescape(split[4])
if split[0] != "" || split[1] != post.ChannelId || split[2] != post.UserId || strings.Contains(split[4], "/") {
l4g.Warn(utils.T("api.file.migrate_filenames_to_file_infos.mismatched_filename.warn"), post.Id, post.ChannelId, post.UserId, filename)
}
pathPrefix := fmt.Sprintf("teams/%s/channels/%s/users/%s/%s/", teamId, channelId, userId, oldId)
path := pathPrefix + name
// Open the file and populate the fields of the FileInfo
var info *model.FileInfo
if data, err := ReadFile(path); err != nil {
l4g.Error(utils.T("api.file.migrate_filenames_to_file_infos.file_not_found.error"), post.Id, filename, path, err)
return nil
} else {
var err *model.AppError
info, err = model.GetInfoForBytes(name, data)
if err != nil {
l4g.Warn(utils.T("api.file.migrate_filenames_to_file_infos.info.app_error"), post.Id, filename, err)
}
}
// Generate a new ID because with the old system, you could very rarely get multiple posts referencing the same file
info.Id = model.NewId()
info.CreatorId = post.UserId
info.PostId = post.Id
info.CreateAt = post.CreateAt
info.UpdateAt = post.UpdateAt
info.Path = path
if info.IsImage() {
nameWithoutExtension := name[:strings.LastIndex(name, ".")]
info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg"
info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg"
}
return info
}
func WriteFile(f []byte, path string) *model.AppError {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
var auth aws.Auth
auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
@@ -556,7 +737,7 @@ func WriteFile(f []byte, path string) *model.AppError {
return model.NewLocAppError("WriteFile", "api.file.write_file.s3.app_error", nil, err.Error())
}
} else if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_LOCAL {
if err := WriteFileLocally(f, utils.Cfg.FileSettings.Directory+path); err != nil {
if err := writeFileLocally(f, utils.Cfg.FileSettings.Directory+path); err != nil {
return err
}
} else {
@@ -568,9 +749,7 @@ func WriteFile(f []byte, path string) *model.AppError {
func MoveFile(oldPath, newPath string) *model.AppError {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
fileData := make(chan []byte)
go readFile(oldPath, fileData)
fileBytes := <-fileData
fileBytes, _ := ReadFile(oldPath)
if fileBytes == nil {
return model.NewLocAppError("moveFile", "api.file.move_file.get_from_s3.app_error", nil, "")
@@ -606,7 +785,7 @@ func MoveFile(oldPath, newPath string) *model.AppError {
return nil
}
func WriteFileLocally(f []byte, path string) *model.AppError {
func writeFileLocally(f []byte, path string) *model.AppError {
if err := os.MkdirAll(filepath.Dir(path), 0774); err != nil {
directory, _ := filepath.Abs(filepath.Dir(path))
return model.NewLocAppError("WriteFile", "api.file.write_file_locally.create_dir.app_error", nil, "directory="+directory+", err="+err.Error())
@@ -620,7 +799,6 @@ func WriteFileLocally(f []byte, path string) *model.AppError {
}
func ReadFile(path string) ([]byte, *model.AppError) {
if utils.Cfg.FileSettings.DriverName == model.IMAGE_DRIVER_S3 {
var auth aws.Auth
auth.AccessKey = utils.Cfg.FileSettings.AmazonS3AccessKeyId
@@ -668,7 +846,6 @@ func openFileWriteStream(path string) (io.Writer, *model.AppError) {
fileHandle.Chmod(0644)
return fileHandle, nil
}
}
return nil, model.NewLocAppError("openFileWriteStream", "api.file.open_file_write_stream.configured.app_error", nil, "")

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

@@ -1,70 +0,0 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package api
import (
"github.com/mattermost/platform/utils"
"testing"
"time"
)
func BenchmarkUploadFile(b *testing.B) {
th := Setup().InitBasic()
Client := th.BasicClient
channel := th.BasicChannel
testPoster := NewAutoPostCreator(Client, channel.Id)
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
testPoster.UploadTestFile()
}
}
func BenchmarkGetFile(b *testing.B) {
th := Setup().InitBasic()
Client := th.BasicClient
channel := th.BasicChannel
testPoster := NewAutoPostCreator(Client, channel.Id)
filenames, err := testPoster.UploadTestFile()
if err == false {
b.Fatal("Unable to upload file for benchmark")
}
// wait a bit for files to ready
time.Sleep(5 * time.Second)
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, downErr := Client.GetFile(filenames[0]+"?h="+generatePublicLinkHash(filenames[0], *utils.Cfg.FileSettings.PublicLinkSalt), true); downErr != nil {
b.Fatal(downErr)
}
}
}
func BenchmarkGetPublicLink(b *testing.B) {
th := Setup().InitBasic()
Client := th.BasicClient
channel := th.BasicChannel
testPoster := NewAutoPostCreator(Client, channel.Id)
filenames, err := testPoster.UploadTestFile()
if err == false {
b.Fatal("Unable to upload file for benchmark")
}
// wait a bit for files to ready
time.Sleep(5 * time.Second)
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, downErr := Client.GetPublicLink(filenames[0]); downErr != nil {
b.Fatal(downErr)
}
}
}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@@ -49,6 +49,7 @@ func InitPost() {
BaseRoutes.NeedPost.Handle("/delete", ApiUserRequired(deletePost)).Methods("POST")
BaseRoutes.NeedPost.Handle("/before/{offset:[0-9]+}/{num_posts:[0-9]+}", ApiUserRequired(getPostsBefore)).Methods("GET")
BaseRoutes.NeedPost.Handle("/after/{offset:[0-9]+}/{num_posts:[0-9]+}", ApiUserRequired(getPostsAfter)).Methods("GET")
BaseRoutes.NeedPost.Handle("/get_file_infos", ApiUserRequired(getFileInfosForPost)).Methods("GET")
}
func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -135,48 +136,26 @@ func CreatePost(c *Context, post *model.Post, triggerWebhooks bool) (*model.Post
post.Hashtags, _ = model.ParseHashtags(post.Message)
if len(post.Filenames) > 0 {
doRemove := false
for i := len(post.Filenames) - 1; i >= 0; i-- {
path := post.Filenames[i]
doRemove = false
if model.UrlRegex.MatchString(path) {
continue
} else if model.PartialUrlRegex.MatchString(path) {
matches := model.PartialUrlRegex.FindAllStringSubmatch(path, -1)
if len(matches) == 0 || len(matches[0]) < 4 {
doRemove = true
}
channelId := matches[0][1]
if channelId != post.ChannelId {
doRemove = true
}
userId := matches[0][2]
if userId != post.UserId {
doRemove = true
}
} else {
doRemove = true
}
if doRemove {
l4g.Error(utils.T("api.post.create_post.bad_filename.error"), path)
post.Filenames = append(post.Filenames[:i], post.Filenames[i+1:]...)
}
}
}
var rpost *model.Post
if result := <-Srv.Store.Post().Save(post); result.Err != nil {
return nil, result.Err
} else {
rpost = result.Data.(*model.Post)
go handlePostEvents(c, rpost, triggerWebhooks)
}
if len(post.FileIds) > 0 {
// There's a rare bug where the client sends up duplicate FileIds so protect against that
post.FileIds = utils.RemoveDuplicatesFromStringArray(post.FileIds)
for _, fileId := range post.FileIds {
if result := <-Srv.Store.FileInfo().AttachToPost(fileId, post.Id); result.Err != nil {
l4g.Error(utils.T("api.post.create_post.attach_files.error"), post.Id, post.FileIds, c.Session.UserId, result.Err)
}
}
}
go handlePostEvents(c, rpost, triggerWebhooks)
return rpost, nil
}
@@ -566,6 +545,7 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *
pchan := Srv.Store.User().GetProfiles(c.TeamId)
dpchan := Srv.Store.User().GetDirectProfiles(c.Session.UserId)
mchan := Srv.Store.Channel().GetMembers(post.ChannelId)
fchan := Srv.Store.FileInfo().GetForPost(post.Id)
var profileMap map[string]*model.User
if result := <-pchan; result.Err != nil {
@@ -785,12 +765,18 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *
message.Add("sender_name", senderName)
message.Add("team_id", team.Id)
if len(post.Filenames) != 0 {
if len(post.FileIds) != 0 {
message.Add("otherFile", "true")
for _, filename := range post.Filenames {
ext := filepath.Ext(filename)
if model.IsFileExtImage(ext) {
var infos []*model.FileInfo
if result := <-fchan; result.Err != nil {
l4g.Warn(utils.T("api.post.send_notifications.files.error"), post.Id, result.Err)
} else {
infos = result.Data.([]*model.FileInfo)
}
for _, info := range infos {
if info.IsImage() {
message.Add("image", "true")
break
}
@@ -915,22 +901,29 @@ func sendNotificationEmail(c *Context, post *model.Post, user *model.User, chann
}
func getMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string {
if len(strings.TrimSpace(post.Message)) != 0 || len(post.Filenames) == 0 {
if len(strings.TrimSpace(post.Message)) != 0 || len(post.FileIds) == 0 {
return post.Message
}
// extract the filenames from their paths and determine what type of files are attached
filenames := make([]string, len(post.Filenames))
var infos []*model.FileInfo
if result := <-Srv.Store.FileInfo().GetForPost(post.Id); result.Err != nil {
l4g.Warn(utils.T("api.post.get_message_for_notification.get_files.error"), post.Id, result.Err)
} else {
infos = result.Data.([]*model.FileInfo)
}
filenames := make([]string, len(infos))
onlyImages := true
for i, filename := range post.Filenames {
var err error
if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil {
for i, info := range infos {
if escaped, err := url.QueryUnescape(filepath.Base(info.Name)); err != nil {
// this should never error since filepath was escaped using url.QueryEscape
filenames[i] = filepath.Base(filename)
filenames[i] = escaped
} else {
filenames[i] = info.Name
}
ext := filepath.Ext(filename)
onlyImages = onlyImages && model.IsFileExtImage(ext)
onlyImages = onlyImages && info.IsImage()
}
props := map[string]interface{}{"Filenames": strings.Join(filenames, ", ")}
@@ -1099,9 +1092,6 @@ func SendEphemeralPost(teamId, userId string, post *model.Post) {
if post.Props == nil {
post.Props = model.StringInterface{}
}
if post.Filenames == nil {
post.Filenames = []string{}
}
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE, "", post.ChannelId, userId, nil)
message.Add("post", post.ToJson())
@@ -1156,9 +1146,13 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
hashtags, _ := model.ParseHashtags(post.Message)
newPost := &model.Post{}
*newPost = *oldPost
if result := <-Srv.Store.Post().Update(oldPost, post.Message, hashtags); result.Err != nil {
newPost.Message = post.Message
newPost.Hashtags, _ = model.ParseHashtags(post.Message)
if result := <-Srv.Store.Post().Update(newPost, oldPost); result.Err != nil {
c.Err = result.Err
return
} else {
@@ -1449,7 +1443,7 @@ func deletePost(c *Context, w http.ResponseWriter, r *http.Request) {
message.Add("post", post.ToJson())
go Publish(message)
go DeletePostFiles(c.TeamId, post)
go DeletePostFiles(post)
go DeleteFlaggedPost(c.Session.UserId, post)
result := make(map[string]string)
@@ -1465,17 +1459,13 @@ func DeleteFlaggedPost(userId string, post *model.Post) {
}
}
func DeletePostFiles(teamId string, post *model.Post) {
if len(post.Filenames) == 0 {
func DeletePostFiles(post *model.Post) {
if len(post.FileIds) != 0 {
return
}
prefix := "teams/" + teamId + "/channels/" + post.ChannelId + "/users/" + post.UserId + "/"
for _, filename := range post.Filenames {
splitUrl := strings.Split(filename, "/")
oldPath := prefix + splitUrl[len(splitUrl)-2] + "/" + splitUrl[len(splitUrl)-1]
newPath := prefix + splitUrl[len(splitUrl)-2] + "/deleted_" + splitUrl[len(splitUrl)-1]
MoveFile(oldPath, newPath)
if result := <-Srv.Store.FileInfo().DeleteForPost(post.Id); result.Err != nil {
l4g.Warn(utils.T("api.post.delete_post_files.app_error.warn"), post.Id, result.Err)
}
}
@@ -1583,3 +1573,59 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Write([]byte(posts.ToJson()))
}
func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
channelId := params["channel_id"]
if len(channelId) != 26 {
c.SetInvalidParam("getFileInfosForPost", "channelId")
return
}
postId := params["post_id"]
if len(postId) != 26 {
c.SetInvalidParam("getFileInfosForPost", "postId")
return
}
pchan := Srv.Store.Post().Get(postId)
fchan := Srv.Store.FileInfo().GetForPost(postId)
if !HasPermissionToChannelContext(c, channelId, model.PERMISSION_READ_CHANNEL) {
return
}
var infos []*model.FileInfo
if result := <-fchan; result.Err != nil {
c.Err = result.Err
return
} else {
infos = result.Data.([]*model.FileInfo)
}
if len(infos) == 0 {
// No FileInfos were returned so check if they need to be created for this post
var post *model.Post
if result := <-pchan; result.Err != nil {
c.Err = result.Err
return
} else {
post = result.Data.(*model.PostList).Posts[postId]
}
if len(post.Filenames) > 0 {
// The post has Filenames that need to be replaced with FileInfos
infos = migrateFilenamesToFileInfos(post)
}
}
etag := model.GetEtagForFileInfos(infos)
if HandleEtag(etag, w, r) {
return
} else {
w.Header().Set(model.HEADER_ETAG_SERVER, etag)
w.Write([]byte(model.FileInfosToJson(infos)))
}
}

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

@@ -1,148 +0,0 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package api
import (
"github.com/mattermost/platform/utils"
"testing"
)
const (
NUM_POSTS = 100
)
func BenchmarkCreatePost(b *testing.B) {
var (
NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
)
th := Setup().InitBasic()
Client := th.BasicClient
channel := th.BasicChannel
testPoster := NewAutoPostCreator(Client, channel.Id)
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
testPoster.CreateTestPosts(NUM_POSTS_RANGE)
}
}
func BenchmarkUpdatePost(b *testing.B) {
var (
NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
UPDATE_POST_LEN = 100
)
th := Setup().InitBasic()
Client := th.BasicClient
channel := th.BasicChannel
testPoster := NewAutoPostCreator(Client, channel.Id)
posts, valid := testPoster.CreateTestPosts(NUM_POSTS_RANGE)
if valid == false {
b.Fatal("Unable to create test posts")
}
for i := range posts {
posts[i].Message = utils.RandString(UPDATE_POST_LEN, utils.ALPHANUMERIC)
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for i := range posts {
if _, err := Client.UpdatePost(posts[i]); err != nil {
b.Fatal(err)
}
}
}
}
func BenchmarkGetPosts(b *testing.B) {
var (
NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
)
th := Setup().InitBasic()
Client := th.BasicClient
channel := th.BasicChannel
testPoster := NewAutoPostCreator(Client, channel.Id)
testPoster.CreateTestPosts(NUM_POSTS_RANGE)
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
Client.Must(Client.GetPosts(channel.Id, 0, NUM_POSTS, ""))
}
}
func BenchmarkSearchPosts(b *testing.B) {
var (
NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
)
th := Setup().InitBasic()
Client := th.BasicClient
channel := th.BasicChannel
testPoster := NewAutoPostCreator(Client, channel.Id)
testPoster.CreateTestPosts(NUM_POSTS_RANGE)
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
Client.Must(Client.SearchPosts("nothere", false))
Client.Must(Client.SearchPosts("n", false))
Client.Must(Client.SearchPosts("#tag", false))
}
}
func BenchmarkEtagCache(b *testing.B) {
var (
NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
)
th := Setup().InitBasic()
Client := th.BasicClient
channel := th.BasicChannel
testPoster := NewAutoPostCreator(Client, channel.Id)
testPoster.CreateTestPosts(NUM_POSTS_RANGE)
etag := Client.Must(Client.GetPosts(channel.Id, 0, NUM_POSTS/2, "")).Etag
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
Client.Must(Client.GetPosts(channel.Id, 0, NUM_POSTS/2, etag))
}
}
func BenchmarkDeletePosts(b *testing.B) {
var (
NUM_POSTS_RANGE = utils.Range{NUM_POSTS, NUM_POSTS}
)
th := Setup().InitBasic()
Client := th.BasicClient
channel := th.BasicChannel
testPoster := NewAutoPostCreator(Client, channel.Id)
posts, valid := testPoster.CreateTestPosts(NUM_POSTS_RANGE)
if valid == false {
b.Fatal("Unable to create test posts")
}
// Benchmark Start
b.ResetTimer()
for i := 0; i < b.N; i++ {
for i := range posts {
Client.Must(Client.DeletePost(channel.Id, posts[i].Id))
}
}
}

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

@@ -15,6 +15,7 @@ import (
"time"
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
)
@@ -23,15 +24,12 @@ func TestCreatePost(t *testing.T) {
Client := th.BasicClient
team := th.BasicTeam
team2 := th.CreateTeam(th.BasicClient)
user1 := th.BasicUser
user3 := th.CreateUser(th.BasicClient)
LinkUserToTeam(user3, team2)
channel1 := th.BasicChannel
channel2 := th.CreateChannel(Client, team)
filenames := []string{"/12345678901234567890123456/12345678901234567890123456/12345678901234567890123456/test.png", "/" + channel1.Id + "/" + user1.Id + "/test.png", "www.mattermost.com/fake/url", "junk"}
post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a", Filenames: filenames}
post1 := &model.Post{ChannelId: channel1.Id, Message: "#hashtag a" + model.NewId() + "a"}
rpost1, err := Client.CreatePost(post1)
if err != nil {
t.Fatal(err)
@@ -45,8 +43,8 @@ func TestCreatePost(t *testing.T) {
t.Fatal("hashtag didn't match")
}
if len(rpost1.Data.(*model.Post).Filenames) != 2 {
t.Fatal("filenames didn't parse correctly")
if len(rpost1.Data.(*model.Post).FileIds) != 0 {
t.Fatal("shouldn't have files")
}
post2 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a", RootId: rpost1.Data.(*model.Post).Id}
@@ -109,6 +107,35 @@ func TestCreatePost(t *testing.T) {
if _, err = Client.DoApiPost("/channels/"+channel3.Id+"/create", "garbage"); err == nil {
t.Fatal("should have been an error")
}
fileIds := make([]string, 4)
if data, err := readTestFile("test.png"); err != nil {
t.Fatal(err)
} else {
for i := 0; i < 3; i++ {
fileIds[i] = Client.MustGeneric(Client.UploadPostAttachment(data, channel3.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
}
}
// Make sure duplicated file ids are removed
fileIds[3] = fileIds[0]
post9 := &model.Post{
ChannelId: channel3.Id,
Message: "test",
FileIds: fileIds,
}
if resp, err := Client.CreatePost(post9); err != nil {
t.Fatal(err)
} else if rpost9 := resp.Data.(*model.Post); len(rpost9.FileIds) != 3 {
t.Fatal("post should have 3 files")
} else {
infos := store.Must(Srv.Store.FileInfo().GetForPost(rpost9.Id)).([]*model.FileInfo)
if len(infos) != 3 {
t.Fatal("should've attached all 3 files to post")
}
}
}
func testCreatePostWithOutgoingHook(
@@ -800,10 +827,8 @@ func TestFuzzyPosts(t *testing.T) {
Client := th.BasicClient
channel1 := th.BasicChannel
filenames := []string{"junk"}
for i := 0; i < len(utils.FUZZY_STRINGS_POSTS); i++ {
post := &model.Post{ChannelId: channel1.Id, Message: utils.FUZZY_STRINGS_POSTS[i], Filenames: filenames}
post := &model.Post{ChannelId: channel1.Id, Message: utils.FUZZY_STRINGS_POSTS[i]}
_, err := Client.CreatePost(post)
if err != nil {
@@ -1150,19 +1175,49 @@ func TestGetFlaggedPosts(t *testing.T) {
}
func TestGetMessageForNotification(t *testing.T) {
Setup()
Setup().InitBasic()
testPng := store.Must(Srv.Store.FileInfo().Save(&model.FileInfo{
CreatorId: model.NewId(),
Path: "test1.png",
Name: "test1.png",
MimeType: "image/png",
})).(*model.FileInfo)
testJpg1 := store.Must(Srv.Store.FileInfo().Save(&model.FileInfo{
CreatorId: model.NewId(),
Path: "test2.jpg",
Name: "test2.jpg",
MimeType: "image/jpeg",
})).(*model.FileInfo)
testFile := store.Must(Srv.Store.FileInfo().Save(&model.FileInfo{
CreatorId: model.NewId(),
Path: "test1.go",
Name: "test1.go",
MimeType: "text/plain",
})).(*model.FileInfo)
testJpg2 := store.Must(Srv.Store.FileInfo().Save(&model.FileInfo{
CreatorId: model.NewId(),
Path: "test3.jpg",
Name: "test3.jpg",
MimeType: "image/jpeg",
})).(*model.FileInfo)
translateFunc := utils.GetUserTranslations("en")
post := &model.Post{
Message: "test",
Filenames: model.StringArray{},
Id: model.NewId(),
Message: "test",
}
if getMessageForNotification(post, translateFunc) != "test" {
t.Fatal("should've returned message text")
}
post.Filenames = model.StringArray{"test1.png"}
post.FileIds = model.StringArray{testPng.Id}
store.Must(Srv.Store.FileInfo().AttachToPost(testPng.Id, post.Id))
if getMessageForNotification(post, translateFunc) != "test" {
t.Fatal("should've returned message text, even with attachments")
}
@@ -1172,18 +1227,60 @@ func TestGetMessageForNotification(t *testing.T) {
t.Fatal("should've returned number of images:", message)
}
post.Filenames = model.StringArray{"test1.png", "test2.jpg"}
post.FileIds = model.StringArray{testPng.Id, testJpg1.Id}
store.Must(Srv.Store.FileInfo().AttachToPost(testJpg1.Id, post.Id))
if message := getMessageForNotification(post, translateFunc); message != "2 images sent: test1.png, test2.jpg" {
t.Fatal("should've returned number of images:", message)
}
post.Filenames = model.StringArray{"test1.go"}
post.Id = model.NewId()
post.FileIds = model.StringArray{testFile.Id}
store.Must(Srv.Store.FileInfo().AttachToPost(testFile.Id, post.Id))
if message := getMessageForNotification(post, translateFunc); message != "1 file sent: test1.go" {
t.Fatal("should've returned number of files:", message)
}
post.Filenames = model.StringArray{"test1.go", "test2.jpg"}
if message := getMessageForNotification(post, translateFunc); message != "2 files sent: test1.go, test2.jpg" {
store.Must(Srv.Store.FileInfo().AttachToPost(testJpg2.Id, post.Id))
post.FileIds = model.StringArray{testFile.Id, testJpg2.Id}
if message := getMessageForNotification(post, translateFunc); message != "2 files sent: test1.go, test3.jpg" {
t.Fatal("should've returned number of mixed files:", message)
}
}
func TestGetFileInfosForPost(t *testing.T) {
th := Setup().InitBasic()
Client := th.BasicClient
channel1 := th.BasicChannel
fileIds := make([]string, 3, 3)
if data, err := readTestFile("test.png"); err != nil {
t.Fatal(err)
} else {
for i := 0; i < 3; i++ {
fileIds[i] = Client.MustGeneric(Client.UploadPostAttachment(data, channel1.Id, "test.png")).(*model.FileUploadResponse).FileInfos[0].Id
}
}
post1 := Client.Must(Client.CreatePost(&model.Post{
ChannelId: channel1.Id,
Message: "test",
FileIds: fileIds,
})).Data.(*model.Post)
var etag string
if infos, err := Client.GetFileInfosForPost(channel1.Id, post1.Id, ""); err != nil {
t.Fatal(err)
} else if len(infos) != 3 {
t.Fatal("should've received 3 files")
} else if Client.Etag == "" {
t.Fatal("should've received etag")
} else {
etag = Client.Etag
}
if infos, err := Client.GetFileInfosForPost(channel1.Id, post1.Id, etag); err != nil {
t.Fatal(err)
} else if len(infos) != 0 {
t.Fatal("should've returned nothing because of etag")
}
}

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

@@ -829,14 +829,6 @@
"id": "api.emoji.upload.large_image.app_error",
"translation": "Unable to create emoji. Image must be at most 128 by 128 pixels."
},
{
"id": "api.file.file_upload.exceeds",
"translation": "File exceeds max image size."
},
{
"id": "api.file.get_file.not_found.app_error",
"translation": "Could not find file."
},
{
"id": "api.file.get_file.public_disabled.app_error",
"translation": "Public links have been disabled by the system administrator"
@@ -849,30 +841,98 @@
"id": "api.file.get_public_link.disabled.app_error",
"translation": "Public links have been disabled"
},
{
"id": "api.file.get_public_link.no_post.app_error",
"translation": "Unable to get public link for file. File must be attached to a post that can be read by the current user."
},
{
"id": "api.file.get_file_preview.no_thumbnail.app_error",
"translation": "File doesn't have a preview image"
},
{
"id": "api.file.get_file_thumbnail.no_thumbnail.app_error",
"translation": "File doesn't have a thumbnail image"
},
{
"id": "api.file.get_public_file_old.storage.app_error",
"translation": "Unable to get file. Image storage is not configured."
},
{
"id": "api.file.get_public_file_old.storage.app_error",
"translation": "Unable to get file. Image storage is not configured."
},
{
"id": "api.file.get_info_for_request.no_post.app_error",
"translation": "Unable to get info for file. File must be attached to a post that can be read by the current user."
},
{
"id": "api.file.get_info_for_request.storage.app_error",
"translation": "Unable to get info for file. Image storage is not configured."
},
{
"id": "api.file.handle_images_forget.decode.error",
"translation": "Unable to decode image channelId=%v userId=%v filename=%v err=%v"
"translation": "Unable to decode image err=%v"
},
{
"id": "api.file.handle_images_forget.encode_jpeg.error",
"translation": "Unable to encode image as jpeg channelId=%v userId=%v filename=%v err=%v"
"translation": "Unable to encode image as jpeg path=%v err=%v"
},
{
"id": "api.file.handle_images_forget.encode_preview.error",
"translation": "Unable to encode image as preview jpg channelId=%v userId=%v filename=%v err=%v"
"translation": "Unable to encode image as preview jpg path=%v err=%v"
},
{
"id": "api.file.handle_images_forget.upload_preview.error",
"translation": "Unable to upload preview channelId=%v userId=%v filename=%v err=%v"
"translation": "Unable to upload preview path=%v err=%v"
},
{
"id": "api.file.handle_images_forget.upload_thumb.error",
"translation": "Unable to upload thumbnail channelId=%v userId=%v filename=%v err=%v"
"translation": "Unable to upload thumbnail path=%v err=%v"
},
{
"id": "api.file.init.debug",
"translation": "Initializing file api routes"
},
{
"id": "api.file.migrate_filenames_to_file_infos.channel.app_error",
"translation": "Unable to get channel when migrating post to use FileInfos, post_id=%v, channel_id=%v, err=%v"
},
{
"id": "api.file.migrate_filenames_to_file_infos.file_not_found.warn",
"translation": "Unable to find file when migrating post to use FileInfos, post_id=%v, filename=%v, path=%v, err=%v"
},
{
"id": "api.file.migrate_filenames_to_file_infos.info.app_error",
"translation": "Unable to fully decode file info when migrating post to use FileInfos, post_id=%v, filename=%v, err=%v"
},
{
"id": "api.file.migrate_filenames_to_file_infos.mismatched_filename.warn",
"translation": "Found an unusual filename when migrating post to use FileInfos, post_id=%v, channel_id=%v, user_id=%v, filename=%v"
},
{
"id": "api.file.migrate_filenames_to_file_infos.no_filenames.warn",
"translation": "Unable to migrate post to use FileInfos with an empty Filenames field, post_id=%v"
},
{
"id": "api.file.migrate_filenames_to_file_infos.save_file_info.warn",
"translation": "Unable to save post when migrating post to use FileInfos, post_id=%v, new_file_ids=%v, old_filenames=%v, err=%v"
},
{
"id": "api.file.migrate_filenames_to_file_infos.save_post.warn",
"translation": "Unable to save file info when migrating post to use FileInfos, post_id=%v, file_id=%v, filename=%v, err=%v"
},
{
"id": "api.file.migrate_filenames_to_file_infos.team_id.app_error",
"translation": "Unable to find team for FileInfos, post_id=%v, filenames=%v"
},
{
"id": "api.file.migrate_filenames_to_file_infos.teams.app_error",
"translation": "Unable to get teams when migrating post to use FileInfos, post_id=%v, err=%v"
},
{
"id": "api.file.migrate_filenames_to_file_infos.unexpected_filename.error",
"translation": "Unable to decipher filename when migrating post to use FileInfos, post_id=%v, filename=%v"
},
{
"id": "api.file.move_file.configured.app_error",
"translation": "File storage not configured properly. Please configure for either S3 or local server file storage."
@@ -917,22 +977,6 @@
"id": "api.file.read_file.reading_local.app_error",
"translation": "Encountered an error reading from local server storage"
},
{
"id": "api.file.upload_file.image.app_error",
"translation": "Unable to upload image file."
},
{
"id": "api.file.upload_file.large_image.app_error",
"translation": "Unable to upload image file. File is too large."
},
{
"id": "api.file.upload_file.storage.app_error",
"translation": "Unable to upload file. Image storage is not configured."
},
{
"id": "api.file.upload_file.too_large.app_error",
"translation": "Unable to upload file. File is too large."
},
{
"id": "api.file.write_file.configured.app_error",
"translation": "File storage not configured properly. Please configure for either S3 or local server file storage."
@@ -949,6 +993,18 @@
"id": "api.file.write_file_locally.writing.app_error",
"translation": "Encountered an error writing to local server storage"
},
{
"id": "api.file.upload_file.large_image.app_error",
"translation": "Unable to upload image file. File is too large."
},
{
"id": "api.file.upload_file.storage.app_error",
"translation": "Unable to upload file. Image storage is not configured."
},
{
"id": "api.file.upload_file.too_large.app_error",
"translation": "Unable to upload file. File is too large."
},
{
"id": "api.general.init.debug",
"translation": "Initializing general api routes"
@@ -1161,6 +1217,10 @@
"id": "api.post.check_for_out_of_channel_mentions.message.one",
"translation": "{{.Username}} was mentioned, but they did not receive a notification because they do not belong to this channel."
},
{
"id": "api.post.create_post.attach_files.error",
"translation": "Encountered error attaching files to post, post_id=%s, user_id=%s, file_ids=%v, err=%v"
},
{
"id": "api.post.create_post.bad_filename.error",
"translation": "Bad filename discarded, filename=%v"
@@ -1197,6 +1257,14 @@
"id": "api.post.delete_post.permissions.app_error",
"translation": "You do not have the appropriate permissions"
},
{
"id": "api.post.delete_post_files.app_error.warn",
"translation": "Encountered error when deleting files for post, post_id=%v, err=%v"
},
{
"id": "api.post.get_message_for_notification.get_files.error",
"translation": "Encountered error when getting files for notification message, post_id=%v, err=%v"
},
{
"id": "api.post.get_message_for_notification.files_sent",
"translation": {
@@ -1287,6 +1355,10 @@
"id": "api.post.send_notifications_and_forget.comment_thread.error",
"translation": "Failed to retrieve comment thread posts in notifications root_post_id=%v, err=%v"
},
{
"id": "api.post.send_notifications_and_forget.files.error",
"translation": "Failed to get files for post notification post_id=%v, err=%v"
},
{
"id": "api.post.send_notifications_and_forget.get_teams.error",
"translation": "Failed to get teams when sending cross-team DM user_id=%v, err=%v"
@@ -3423,6 +3495,10 @@
"id": "model.post.is_valid.filenames.app_error",
"translation": "Invalid filenames"
},
{
"id": "model.post.is_valid.file_ids.app_error",
"translation": "Invalid file ids"
},
{
"id": "model.post.is_valid.hashtags.app_error",
"translation": "Invalid hashtags"
@@ -3875,6 +3951,10 @@
"id": "store.sql_channel.get_extra_members.app_error",
"translation": "We couldn't get the extra info for channel members"
},
{
"id": "store.sql_channel.get_for_post.app_error",
"translation": "We couldn't get the channel for the given post"
},
{
"id": "store.sql_channel.get_member.app_error",
"translation": "We couldn't get the channel member"
@@ -3887,6 +3967,10 @@
"id": "store.sql_channel.get_member_count.app_error",
"translation": "We couldn't get the channel member count"
},
{
"id": "store.sql_channel.get_member_for_post.app_error",
"translation": "We couldn't get the channel member for the given post"
},
{
"id": "store.sql_channel.get_members.app_error",
"translation": "We couldn't get the channel members"
@@ -4071,6 +4155,30 @@
"id": "store.sql_emoji.save.app_error",
"translation": "We couldn't save the emoji"
},
{
"id": "store.sql_file_info.attach_to_post.app_error",
"translation": "We couldn't attach the file info to the post"
},
{
"id": "store.sql_file_info.delete_for_post.app_error",
"translation": "We couldn't delete the file info to the post"
},
{
"id": "store.sql_file_info.get.app_error",
"translation": "We couldn't get the file info"
},
{
"id": "store.sql_file_info.get_by_path.app_error",
"translation": "We couldn't get the file info by path"
},
{
"id": "store.sql_file_info.get_for_post.app_error",
"translation": "We couldn't get the file info for the post"
},
{
"id": "store.sql_file_info.save.app_error",
"translation": "We couldn't save the file info"
},
{
"id": "store.sql_license.get.app_error",
"translation": "We encountered an error getting the license"

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

@@ -124,6 +124,10 @@ func (c *Client) GetGeneralRoute() string {
return "/general"
}
func (c *Client) GetFileRoute(fileId string) string {
return fmt.Sprintf("/files/%v", fileId)
}
func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppError) {
rq, _ := http.NewRequest("POST", c.Url+url, strings.NewReader(data))
rq.Header.Set("Content-Type", contentType)
@@ -1289,8 +1293,33 @@ func (c *Client) UploadProfileFile(data []byte, contentType string) (*Result, *A
return c.uploadFile(c.ApiUrl+"/users/newimage", data, contentType)
}
func (c *Client) UploadPostAttachment(data []byte, contentType string) (*Result, *AppError) {
return c.uploadFile(c.ApiUrl+c.GetTeamRoute()+"/files/upload", data, contentType)
func (c *Client) UploadPostAttachment(data []byte, channelId string, filename string) (*FileUploadResponse, *AppError) {
c.clearExtraProperties()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if part, err := writer.CreateFormFile("files", filename); err != nil {
return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.file.app_error", nil, err.Error())
} else if _, err = io.Copy(part, bytes.NewBuffer(data)); err != nil {
return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.file.app_error", nil, err.Error())
}
if part, err := writer.CreateFormField("channel_id"); err != nil {
return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.channel_id.app_error", nil, err.Error())
} else if _, err = io.Copy(part, strings.NewReader(channelId)); err != nil {
return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.channel_id.app_error", nil, err.Error())
}
if err := writer.Close(); err != nil {
return nil, NewLocAppError("UploadPostAttachment", "model.client.upload_post_attachment.writer.app_error", nil, err.Error())
}
if result, err := c.uploadFile(c.ApiUrl+c.GetTeamRoute()+"/files/upload", body.Bytes(), writer.FormDataContentType()); err != nil {
return nil, err
} else {
return result.Data.(*FileUploadResponse), nil
}
}
func (c *Client) uploadFile(url string, data []byte, contentType string) (*Result, *AppError) {
@@ -1312,55 +1341,51 @@ func (c *Client) uploadFile(url string, data []byte, contentType string) (*Resul
}
}
func (c *Client) GetFile(url string, isFullUrl bool) (*Result, *AppError) {
var rq *http.Request
if isFullUrl {
rq, _ = http.NewRequest("GET", url, nil)
func (c *Client) GetFile(fileId string) (io.ReadCloser, *AppError) {
if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get", "", ""); err != nil {
return nil, err
} else {
rq, _ = http.NewRequest("GET", c.ApiUrl+c.GetTeamRoute()+"/files/get"+url, nil)
}
if len(c.AuthToken) > 0 {
rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken)
}
if rp, err := c.HttpClient.Do(rq); err != nil {
return nil, NewLocAppError(url, "model.client.connecting.app_error", nil, err.Error())
} else if rp.StatusCode >= 300 {
return nil, AppErrorFromJson(rp.Body)
} else {
defer closeBody(rp)
return &Result{rp.Header.Get(HEADER_REQUEST_ID),
rp.Header.Get(HEADER_ETAG_SERVER), rp.Body}, nil
c.fillInExtraProperties(r)
return r.Body, nil
}
}
func (c *Client) GetFileInfo(url string) (*Result, *AppError) {
var rq *http.Request
rq, _ = http.NewRequest("GET", c.ApiUrl+c.GetTeamRoute()+"/files/get_info"+url, nil)
if len(c.AuthToken) > 0 {
rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken)
}
if rp, err := c.HttpClient.Do(rq); err != nil {
return nil, NewLocAppError(url, "model.client.connecting.app_error", nil, err.Error())
} else if rp.StatusCode >= 300 {
return nil, AppErrorFromJson(rp.Body)
func (c *Client) GetFileThumbnail(fileId string) (io.ReadCloser, *AppError) {
if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_thumbnail", "", ""); err != nil {
return nil, err
} else {
defer closeBody(rp)
return &Result{rp.Header.Get(HEADER_REQUEST_ID),
rp.Header.Get(HEADER_ETAG_SERVER), FileInfoFromJson(rp.Body)}, nil
c.fillInExtraProperties(r)
return r.Body, nil
}
}
func (c *Client) GetPublicLink(filename string) (*Result, *AppError) {
if r, err := c.DoApiPost(c.GetTeamRoute()+"/files/get_public_link", MapToJson(map[string]string{"filename": filename})); err != nil {
func (c *Client) GetFilePreview(fileId string) (io.ReadCloser, *AppError) {
if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_preview", "", ""); err != nil {
return nil, err
} else {
defer closeBody(r)
return &Result{r.Header.Get(HEADER_REQUEST_ID),
r.Header.Get(HEADER_ETAG_SERVER), StringFromJson(r.Body)}, nil
c.fillInExtraProperties(r)
return r.Body, nil
}
}
func (c *Client) GetFileInfo(fileId string) (*FileInfo, *AppError) {
if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_info", "", ""); err != nil {
return nil, err
} else {
defer closeBody(r)
c.fillInExtraProperties(r)
return FileInfoFromJson(r.Body), nil
}
}
func (c *Client) GetPublicLink(fileId string) (string, *AppError) {
if r, err := c.DoApiGet(c.GetFileRoute(fileId)+"/get_public_link", "", ""); err != nil {
return "", err
} else {
defer closeBody(r)
c.fillInExtraProperties(r)
return StringFromJson(r.Body), nil
}
}
@@ -1930,3 +1955,17 @@ func (c *Client) GetWebrtcToken() (map[string]string, *AppError) {
return MapFromJson(r.Body), nil
}
}
// GetFileInfosForPost returns a list of FileInfo objects for a given post id, if successful.
// Otherwise, it returns an error.
func (c *Client) GetFileInfosForPost(channelId string, postId string, etag string) ([]*FileInfo, *AppError) {
c.clearExtraProperties()
if r, err := c.DoApiGet(c.GetChannelRoute(channelId)+fmt.Sprintf("/posts/%v/get_file_infos", postId), "", etag); err != nil {
return nil, err
} else {
defer closeBody(r)
c.fillInExtraProperties(r)
return FileInfosFromJson(r.Body), nil
}
}

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

@@ -34,7 +34,7 @@ type CompliancePost struct {
PostType string
PostProps string
PostHashtags string
PostFilenames string
PostFileIds string
}
func CompliancePostHeader() []string {
@@ -60,7 +60,7 @@ func CompliancePostHeader() []string {
"PostType",
"PostProps",
"PostHashtags",
"PostFilenames",
"PostFileIds",
}
}
@@ -99,6 +99,6 @@ func (me *CompliancePost) Row() []string {
me.PostType,
me.PostProps,
me.PostHashtags,
me.PostFilenames,
me.PostFileIds,
}
}

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

@@ -14,7 +14,7 @@ func TestCompliancePostHeader(t *testing.T) {
}
func TestCompliancePost(t *testing.T) {
o := CompliancePost{TeamName: "test", PostFilenames: "files", PostCreateAt: GetMillis()}
o := CompliancePost{TeamName: "test", PostFileIds: "files", PostCreateAt: GetMillis()}
r := o.Row()
if r[0] != "test" {

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

@@ -14,8 +14,8 @@ var (
)
type FileUploadResponse struct {
Filenames []string `json:"filenames"`
ClientIds []string `json:"client_ids"`
FileInfos []*FileInfo `json:"file_infos"`
ClientIds []string `json:"client_ids"`
}
func FileUploadResponseFromJson(data io.Reader) *FileUploadResponse {

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

@@ -6,54 +6,31 @@ package model
import (
"bytes"
"encoding/json"
"image"
"image/gif"
"io"
"mime"
"path/filepath"
"strings"
)
type FileInfo struct {
Filename string `json:"filename"`
Size int `json:"size"`
Id string `json:"id"`
CreatorId string `json:"user_id"`
PostId string `json:"post_id,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
DeleteAt int64 `json:"delete_at"`
Path string `json:"-"` // not sent back to the client
ThumbnailPath string `json:"-"` // not sent back to the client
PreviewPath string `json:"-"` // not sent back to the client
Name string `json:"name"`
Extension string `json:"extension"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
HasPreviewImage bool `json:"has_preview_image"`
}
func GetInfoForBytes(filename string, data []byte) (*FileInfo, *AppError) {
size := len(data)
var mimeType string
extension := filepath.Ext(filename)
isImage := IsFileExtImage(extension)
if isImage {
mimeType = GetImageMimeType(extension)
} else {
mimeType = mime.TypeByExtension(extension)
}
if extension != "" && extension[0] == '.' {
// the client expects a file extension without the leading period
extension = extension[1:]
}
hasPreviewImage := isImage
if mimeType == "image/gif" {
// just show the gif itself instead of a preview image for animated gifs
if gifImage, err := gif.DecodeAll(bytes.NewReader(data)); err != nil {
return nil, NewLocAppError("GetInfoForBytes", "model.file_info.get.gif.app_error", nil, "filename="+filename)
} else {
hasPreviewImage = len(gifImage.Image) == 1
}
}
return &FileInfo{
Filename: filename,
Size: size,
Extension: extension,
MimeType: mimeType,
HasPreviewImage: hasPreviewImage,
}, nil
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
HasPreviewImage bool `json:"has_preview_image,omitempty"`
}
func (info *FileInfo) ToJson() string {
@@ -75,3 +52,123 @@ func FileInfoFromJson(data io.Reader) *FileInfo {
return &info
}
}
func FileInfosToJson(infos []*FileInfo) string {
b, err := json.Marshal(infos)
if err != nil {
return ""
} else {
return string(b)
}
}
func FileInfosFromJson(data io.Reader) []*FileInfo {
decoder := json.NewDecoder(data)
var infos []*FileInfo
if err := decoder.Decode(&infos); err != nil {
return nil
} else {
return infos
}
}
func (o *FileInfo) PreSave() {
if o.Id == "" {
o.Id = NewId()
}
if o.CreateAt == 0 {
o.CreateAt = GetMillis()
o.UpdateAt = o.CreateAt
}
}
func (o *FileInfo) IsValid() *AppError {
if len(o.Id) != 26 {
return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.id.app_error", nil, "")
}
if len(o.CreatorId) != 26 {
return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.user_id.app_error", nil, "id="+o.Id)
}
if len(o.PostId) != 0 && len(o.PostId) != 26 {
return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.post_id.app_error", nil, "id="+o.Id)
}
if o.CreateAt == 0 {
return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.create_at.app_error", nil, "id="+o.Id)
}
if o.UpdateAt == 0 {
return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.update_at.app_error", nil, "id="+o.Id)
}
if o.Path == "" {
return NewLocAppError("FileInfo.IsValid", "model.file_info.is_valid.path.app_error", nil, "id="+o.Id)
}
return nil
}
func (o *FileInfo) IsImage() bool {
return strings.HasPrefix(o.MimeType, "image")
}
func GetInfoForBytes(name string, data []byte) (*FileInfo, *AppError) {
info := &FileInfo{
Name: name,
Size: int64(len(data)),
}
var err *AppError
extension := strings.ToLower(filepath.Ext(name))
info.MimeType = mime.TypeByExtension(extension)
if extension != "" && extension[0] == '.' {
// The client expects a file extension without the leading period
info.Extension = extension[1:]
} else {
info.Extension = extension
}
if info.IsImage() {
// Only set the width and height if it's actually an image that we can understand
if config, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil {
info.Width = config.Width
info.Height = config.Height
if info.MimeType == "image/gif" {
// Just show the gif itself instead of a preview image for animated gifs
if gifConfig, err := gif.DecodeAll(bytes.NewReader(data)); err != nil {
// Still return the rest of the info even though it doesn't appear to be an actual gif
info.HasPreviewImage = true
err = NewLocAppError("GetInfoForBytes", "model.file_info.get.gif.app_error", nil, "name="+name)
} else {
info.HasPreviewImage = len(gifConfig.Image) == 1
}
} else {
info.HasPreviewImage = true
}
}
}
return info, err
}
func GetEtagForFileInfos(infos []*FileInfo) string {
if len(infos) == 0 {
return Etag()
}
var maxUpdateAt int64
for _, info := range infos {
if info.UpdateAt > maxUpdateAt {
maxUpdateAt = info.UpdateAt
}
}
return Etag(infos[0].PostId, maxUpdateAt)
}

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

@@ -5,56 +5,137 @@ package model
import (
"encoding/base64"
_ "image/gif"
_ "image/png"
"io/ioutil"
"strings"
"testing"
)
func TestGetInfoForBytes(t *testing.T) {
func TestFileInfoIsValid(t *testing.T) {
info := &FileInfo{
Id: NewId(),
CreatorId: NewId(),
CreateAt: 1234,
UpdateAt: 1234,
PostId: "",
Path: "fake/path.png",
}
if err := info.IsValid(); err != nil {
t.Fatal(err)
}
info.Id = ""
if err := info.IsValid(); err == nil {
t.Fatal("empty Id isn't valid")
}
info.Id = NewId()
info.CreateAt = 0
if err := info.IsValid(); err == nil {
t.Fatal("empty CreateAt isn't valid")
}
info.CreateAt = 1234
info.UpdateAt = 0
if err := info.IsValid(); err == nil {
t.Fatal("empty UpdateAt isn't valid")
}
info.UpdateAt = 1234
info.PostId = NewId()
if err := info.IsValid(); err != nil {
t.Fatal(err)
}
info.Path = ""
if err := info.IsValid(); err == nil {
t.Fatal("empty Path isn't valid")
}
info.Path = "fake/path.png"
if err := info.IsValid(); err != nil {
t.Fatal(err)
}
}
func TestFileInfoIsImage(t *testing.T) {
info := &FileInfo{
MimeType: "image/png",
}
if !info.IsImage() {
t.Fatal("file is an image")
}
info.MimeType = "text/plain"
if info.IsImage() {
t.Fatal("file is not an image")
}
}
func TestGetInfoForFile(t *testing.T) {
fakeFile := make([]byte, 1000)
if info, err := GetInfoForBytes("file.txt", fakeFile); err != nil {
t.Fatal(err)
} else if info.Filename != "file.txt" {
t.Fatalf("Got incorrect filename: %v", info.Filename)
} else if info.Name != "file.txt" {
t.Fatalf("Got incorrect filename: %v", info.Name)
} else if info.Extension != "txt" {
t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.Size != 1000 {
t.Fatalf("Got incorrect size: %v", info.Size)
} else if info.Extension != "txt" {
t.Fatalf("Got incorrect file extension: %v", info.Extension)
} else if !strings.HasPrefix(info.MimeType, "text/plain") {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
} else if info.Width != 0 {
t.Fatalf("Got incorrect width: %v", info.Width)
} else if info.Height != 0 {
t.Fatalf("Got incorrect height: %v", info.Height)
} else if info.HasPreviewImage {
t.Fatalf("Got HasPreviewImage = true for non-image file")
t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
}
if info, err := GetInfoForBytes("file.png", fakeFile); err != nil {
pngFile, err := ioutil.ReadFile("../tests/test.png")
if err != nil {
t.Fatalf("Failed to load test.png: %v", err.Error())
}
if info, err := GetInfoForBytes("test.png", pngFile); err != nil {
t.Fatal(err)
} else if info.Filename != "file.png" {
t.Fatalf("Got incorrect filename: %v", info.Filename)
} else if info.Size != 1000 {
t.Fatalf("Got incorrect size: %v", info.Size)
} else if info.Name != "test.png" {
t.Fatalf("Got incorrect filename: %v", info.Name)
} else if info.Extension != "png" {
t.Fatalf("Got incorrect file extension: %v", info.Extension)
t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.Size != 279591 {
t.Fatalf("Got incorrect size: %v", info.Size)
} else if info.MimeType != "image/png" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
} else if info.Width != 408 {
t.Fatalf("Got incorrect width: %v", info.Width)
} else if info.Height != 336 {
t.Fatalf("Got incorrect height: %v", info.Height)
} else if !info.HasPreviewImage {
t.Fatalf("Got HasPreviewImage = false for image")
t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
}
// base 64 encoded version of handtinywhite.gif from http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever
gifFile, _ := base64.StdEncoding.DecodeString("R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=")
if info, err := GetInfoForBytes("handtinywhite.gif", gifFile); err != nil {
t.Fatal(err)
} else if info.Filename != "handtinywhite.gif" {
t.Fatalf("Got incorrect filename: %v", info.Filename)
} else if info.Name != "handtinywhite.gif" {
t.Fatalf("Got incorrect filename: %v", info.Name)
} else if info.Extension != "gif" {
t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.Size != 35 {
t.Fatalf("Got incorrect size: %v", info.Size)
} else if info.Extension != "gif" {
t.Fatalf("Got incorrect file extension: %v", info.Extension)
} else if info.MimeType != "image/gif" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
} else if info.Width != 1 {
t.Fatalf("Got incorrect width: %v", info.Width)
} else if info.Height != 1 {
t.Fatalf("Got incorrect height: %v", info.Height)
} else if !info.HasPreviewImage {
t.Fatalf("Got HasPreviewImage = false for static gif")
t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
}
animatedGifFile, err := ioutil.ReadFile("../tests/testgif.gif")
@@ -63,29 +144,57 @@ func TestGetInfoForBytes(t *testing.T) {
}
if info, err := GetInfoForBytes("testgif.gif", animatedGifFile); err != nil {
t.Fatal(err)
} else if info.Filename != "testgif.gif" {
t.Fatalf("Got incorrect filename: %v", info.Filename)
} else if info.Name != "testgif.gif" {
t.Fatalf("Got incorrect filename: %v", info.Name)
} else if info.Extension != "gif" {
t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.Size != 38689 {
t.Fatalf("Got incorrect size: %v", info.Size)
} else if info.Extension != "gif" {
t.Fatalf("Got incorrect file extension: %v", info.Extension)
} else if info.MimeType != "image/gif" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
} else if info.Width != 118 {
t.Fatalf("Got incorrect width: %v", info.Width)
} else if info.Height != 118 {
t.Fatalf("Got incorrect height: %v", info.Height)
} else if info.HasPreviewImage {
t.Fatalf("Got HasPreviewImage = true for animated gif")
t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
}
if info, err := GetInfoForBytes("filewithoutextension", fakeFile); err != nil {
t.Fatal(err)
} else if info.Filename != "filewithoutextension" {
t.Fatalf("Got incorrect filename: %v", info.Filename)
} else if info.Name != "filewithoutextension" {
t.Fatalf("Got incorrect filename: %v", info.Name)
} else if info.Extension != "" {
t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.Size != 1000 {
t.Fatalf("Got incorrect size: %v", info.Size)
} else if info.Extension != "" {
t.Fatalf("Got incorrect file extension: %v", info.Extension)
} else if info.MimeType != "" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
} else if info.Width != 0 {
t.Fatalf("Got incorrect width: %v", info.Width)
} else if info.Height != 0 {
t.Fatalf("Got incorrect height: %v", info.Height)
} else if info.HasPreviewImage {
t.Fatalf("Got HasPreviewImage = true for non-image file")
t.Fatalf("Got incorrect has preview image: %v", info.HasPreviewImage)
}
// Always make the extension lower case to make it easier to use in other places
if info, err := GetInfoForBytes("file.TXT", fakeFile); err != nil {
t.Fatal(err)
} else if info.Name != "file.TXT" {
t.Fatalf("Got incorrect filename: %v", info.Name)
} else if info.Extension != "txt" {
t.Fatalf("Got incorrect extension: %v", info.Extension)
}
// Don't error out for image formats we don't support
if info, err := GetInfoForBytes("file.tif", fakeFile); err != nil {
t.Fatal(err)
} else if info.Name != "file.tif" {
t.Fatalf("Got incorrect filename: %v", info.Name)
} else if info.Extension != "tif" {
t.Fatalf("Got incorrect extension: %v", info.Extension)
} else if info.MimeType != "image/tiff" && info.MimeType != "image/x-tiff" {
t.Fatalf("Got incorrect mime type: %v", info.MimeType)
}
}

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

@@ -35,7 +35,8 @@ type Post struct {
Type string `json:"type"`
Props StringInterface `json:"props"`
Hashtags string `json:"hashtags"`
Filenames StringArray `json:"filenames"`
Filenames StringArray `json:"filenames,omitempty"` // Deprecated, do not use this field any more
FileIds StringArray `json:"file_ids,omitempty"`
PendingPostId string `json:"pending_post_id" db:"-"`
}
@@ -118,6 +119,10 @@ func (o *Post) IsValid() *AppError {
return NewLocAppError("Post.IsValid", "model.post.is_valid.filenames.app_error", nil, "id="+o.Id)
}
if utf8.RuneCountInString(ArrayToJson(o.FileIds)) > 150 {
return NewLocAppError("Post.IsValid", "model.post.is_valid.file_ids.app_error", nil, "id="+o.Id)
}
if utf8.RuneCountInString(StringInterfaceToJson(o.Props)) > 8000 {
return NewLocAppError("Post.IsValid", "model.post.is_valid.props.app_error", nil, "id="+o.Id)
}
@@ -145,15 +150,16 @@ func (o *Post) PreSave() {
if o.Filenames == nil {
o.Filenames = []string{}
}
if o.FileIds == nil {
o.FileIds = []string{}
}
}
func (o *Post) MakeNonNil() {
if o.Props == nil {
o.Props = make(map[string]interface{})
}
if o.Filenames == nil {
o.Filenames = []string{}
}
}
func (o *Post) AddProp(key string, value interface{}) {

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

@@ -596,6 +596,36 @@ func (s SqlChannelStore) GetMember(channelId string, userId string) StoreChannel
return storeChannel
}
func (s SqlChannelStore) GetMemberForPost(postId string, userId string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
member := &model.ChannelMember{}
if err := s.GetReplica().SelectOne(
member,
`SELECT
ChannelMembers.*
FROM
ChannelMembers,
Posts
WHERE
ChannelMembers.ChannelId = Posts.ChannelId
AND ChannelMembers.UserId = :UserId
AND Posts.Id = :PostId`, map[string]interface{}{"UserId": userId, "PostId": postId}); err != nil {
result.Err = model.NewLocAppError("SqlChannelStore.GetMemberForPost", "store.sql_channel.get_member_for_post.app_error", nil, "postId="+postId+", err="+err.Error())
} else {
result.Data = member
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (s SqlChannelStore) GetMemberCount(channelId string) StoreChannel {
storeChannel := make(StoreChannel, 1)
@@ -878,6 +908,35 @@ func (s SqlChannelStore) GetAll(teamId string) StoreChannel {
return storeChannel
}
func (s SqlChannelStore) GetForPost(postId string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
channel := &model.Channel{}
if err := s.GetReplica().SelectOne(
channel,
`SELECT
Channels.*
FROM
Channels,
Posts
WHERE
Channels.Id = Posts.ChannelId
AND Posts.Id = :PostId`, map[string]interface{}{"PostId": postId}); err != nil {
result.Err = model.NewLocAppError("SqlChannelStore.GetForPost", "store.sql_channel.get_for_post.app_error", nil, "postId="+postId+", err="+err.Error())
} else {
result.Data = channel
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (s SqlChannelStore) AnalyticsTypeCount(teamId string, channelType string) StoreChannel {
storeChannel := make(StoreChannel, 1)

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

@@ -197,6 +197,29 @@ func TestChannelStoreGet(t *testing.T) {
}
}
func TestChannelStoreGetForPost(t *testing.T) {
Setup()
o1 := Must(store.Channel().Save(&model.Channel{
TeamId: model.NewId(),
DisplayName: "Name",
Name: "a" + model.NewId() + "b",
Type: model.CHANNEL_OPEN,
})).(*model.Channel)
p1 := Must(store.Post().Save(&model.Post{
UserId: model.NewId(),
ChannelId: o1.Id,
Message: "test",
})).(*model.Post)
if r1 := <-store.Channel().GetForPost(p1.Id); r1.Err != nil {
t.Fatal(r1.Err)
} else if r1.Data.(*model.Channel).Id != o1.Id {
t.Fatal("incorrect channel returned")
}
}
func TestChannelStoreDelete(t *testing.T) {
Setup()
@@ -745,6 +768,39 @@ func TestGetMember(t *testing.T) {
}
}
func TestChannelStoreGetMemberForPost(t *testing.T) {
Setup()
o1 := Must(store.Channel().Save(&model.Channel{
TeamId: model.NewId(),
DisplayName: "Name",
Name: "a" + model.NewId() + "b",
Type: model.CHANNEL_OPEN,
})).(*model.Channel)
m1 := Must(store.Channel().SaveMember(&model.ChannelMember{
ChannelId: o1.Id,
UserId: model.NewId(),
NotifyProps: model.GetDefaultChannelNotifyProps(),
})).(*model.ChannelMember)
p1 := Must(store.Post().Save(&model.Post{
UserId: model.NewId(),
ChannelId: o1.Id,
Message: "test",
})).(*model.Post)
if r1 := <-store.Channel().GetMemberForPost(p1.Id, m1.UserId); r1.Err != nil {
t.Fatal(r1.Err)
} else if r1.Data.(*model.ChannelMember).ToJson() != m1.ToJson() {
t.Fatal("invalid returned channel member")
}
if r2 := <-store.Channel().GetMemberForPost(p1.Id, model.NewId()); r2.Err == nil {
t.Fatal("shouldn't have returned a member")
}
}
func TestGetMemberCount(t *testing.T) {
Setup()

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

@@ -199,7 +199,7 @@ func (s SqlComplianceStore) ComplianceExport(job *model.Compliance) StoreChannel
Posts.Type AS PostType,
Posts.Props AS PostProps,
Posts.Hashtags AS PostHashtags,
Posts.Filenames AS PostFilenames
Posts.FileIds AS PostFileIds
FROM
Teams,
Channels,

197
store/sql_file_info_store.go Обычный файл
Просмотреть файл

@@ -0,0 +1,197 @@
// See License.txt for license information.
package store
import (
"github.com/mattermost/platform/model"
)
type SqlFileInfoStore struct {
*SqlStore
}
func NewSqlFileInfoStore(sqlStore *SqlStore) FileInfoStore {
s := &SqlFileInfoStore{sqlStore}
for _, db := range sqlStore.GetAllConns() {
table := db.AddTableWithName(model.FileInfo{}, "FileInfo").SetKeys(false, "Id")
table.ColMap("Id").SetMaxSize(26)
table.ColMap("CreatorId").SetMaxSize(26)
table.ColMap("PostId").SetMaxSize(26)
table.ColMap("Path").SetMaxSize(512)
table.ColMap("ThumbnailPath").SetMaxSize(512)
table.ColMap("PreviewPath").SetMaxSize(512)
table.ColMap("Name").SetMaxSize(256)
table.ColMap("Extension").SetMaxSize(64)
table.ColMap("MimeType").SetMaxSize(256)
}
return s
}
func (fs SqlFileInfoStore) CreateIndexesIfNotExists() {
}
func (fs SqlFileInfoStore) Save(info *model.FileInfo) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
info.PreSave()
if result.Err = info.IsValid(); result.Err != nil {
storeChannel <- result
close(storeChannel)
return
}
if err := fs.GetMaster().Insert(info); err != nil {
result.Err = model.NewLocAppError("SqlFileInfoStore.Save", "store.sql_file_info.save.app_error", nil, err.Error())
} else {
result.Data = info
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (fs SqlFileInfoStore) Get(id string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
info := &model.FileInfo{}
if err := fs.GetReplica().SelectOne(info,
`SELECT
*
FROM
FileInfo
WHERE
Id = :Id
AND DeleteAt = 0`, map[string]interface{}{"Id": id}); err != nil {
result.Err = model.NewLocAppError("SqlFileInfoStore.Get", "store.sql_file_info.get.app_error", nil, "id="+id+", "+err.Error())
} else {
result.Data = info
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (fs SqlFileInfoStore) GetByPath(path string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
info := &model.FileInfo{}
if err := fs.GetReplica().SelectOne(info,
`SELECT
*
FROM
FileInfo
WHERE
Path = :Path
AND DeleteAt = 0`, map[string]interface{}{"Path": path}); err != nil {
result.Err = model.NewLocAppError("SqlFileInfoStore.GetByPath", "store.sql_file_info.get_by_path.app_error", nil, "path="+path+", "+err.Error())
} else {
result.Data = info
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (fs SqlFileInfoStore) GetForPost(postId string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
var infos []*model.FileInfo
if _, err := fs.GetReplica().Select(&infos,
`SELECT
*
FROM
FileInfo
WHERE
PostId = :PostId
AND DeleteAt = 0
ORDER BY
CreateAt`, map[string]interface{}{"PostId": postId}); err != nil {
result.Err = model.NewLocAppError("SqlFileInfoStore.GetForPost",
"store.sql_file_info.get_for_post.app_error", nil, "post_id="+postId+", "+err.Error())
} else {
result.Data = infos
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (fs SqlFileInfoStore) AttachToPost(fileId, postId string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
if _, err := fs.GetMaster().Exec(
`UPDATE
FileInfo
SET
PostId = :PostId
WHERE
Id = :Id
AND PostId = ''`, map[string]interface{}{"PostId": postId, "Id": fileId}); err != nil {
result.Err = model.NewLocAppError("SqlFileInfoStore.AttachToPost",
"store.sql_file_info.attach_to_post.app_error", nil, "post_id="+postId+", file_id="+fileId+", err="+err.Error())
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}
func (fs SqlFileInfoStore) DeleteForPost(postId string) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
if _, err := fs.GetMaster().Exec(
`UPDATE
FileInfo
SET
DeleteAt = :DeleteAt
WHERE
PostId = :PostId`, map[string]interface{}{"DeleteAt": model.GetMillis(), "PostId": postId}); err != nil {
result.Err = model.NewLocAppError("SqlFileInfoStore.DeleteForPost",
"store.sql_file_info.delete_for_post.app_error", nil, "post_id="+postId+", err="+err.Error())
} else {
result.Data = postId
}
storeChannel <- result
close(storeChannel)
}()
return storeChannel
}

208
store/sql_file_info_store_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,208 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package store
import (
"fmt"
"testing"
"github.com/mattermost/platform/model"
)
func TestFileInfoSaveGet(t *testing.T) {
Setup()
info := &model.FileInfo{
CreatorId: model.NewId(),
Path: "file.txt",
}
if result := <-store.FileInfo().Save(info); result.Err != nil {
t.Fatal(result.Err)
} else if returned := result.Data.(*model.FileInfo); len(returned.Id) == 0 {
t.Fatal("should've assigned an id to FileInfo")
} else {
info = returned
}
if result := <-store.FileInfo().Get(info.Id); result.Err != nil {
t.Fatal(result.Err)
} else if returned := result.Data.(*model.FileInfo); returned.Id != info.Id {
t.Log(info)
t.Log(returned)
t.Fatal("should've returned correct FileInfo")
}
info2 := Must(store.FileInfo().Save(&model.FileInfo{
CreatorId: model.NewId(),
Path: "file.txt",
DeleteAt: 123,
})).(*model.FileInfo)
if result := <-store.FileInfo().Get(info2.Id); result.Err == nil {
t.Fatal("shouldn't have gotten deleted file")
}
}
func TestFileInfoSaveGetByPath(t *testing.T) {
Setup()
info := &model.FileInfo{
CreatorId: model.NewId(),
Path: fmt.Sprintf("%v/file.txt", model.NewId()),
}
if result := <-store.FileInfo().Save(info); result.Err != nil {
t.Fatal(result.Err)
} else if returned := result.Data.(*model.FileInfo); len(returned.Id) == 0 {
t.Fatal("should've assigned an id to FileInfo")
} else {
info = returned
}
if result := <-store.FileInfo().GetByPath(info.Path); result.Err != nil {
t.Fatal(result.Err)
} else if returned := result.Data.(*model.FileInfo); returned.Id != info.Id {
t.Log(info)
t.Log(returned)
t.Fatal("should've returned correct FileInfo")
}
info2 := Must(store.FileInfo().Save(&model.FileInfo{
CreatorId: model.NewId(),
Path: "file.txt",
DeleteAt: 123,
})).(*model.FileInfo)
if result := <-store.FileInfo().GetByPath(info2.Id); result.Err == nil {
t.Fatal("shouldn't have gotten deleted file")
}
}
func TestFileInfoGetForPost(t *testing.T) {
Setup()
userId := model.NewId()
postId := model.NewId()
infos := []*model.FileInfo{
{
PostId: postId,
CreatorId: userId,
Path: "file.txt",
},
{
PostId: postId,
CreatorId: userId,
Path: "file.txt",
},
{
PostId: postId,
CreatorId: userId,
Path: "file.txt",
DeleteAt: 123,
},
{
PostId: model.NewId(),
CreatorId: userId,
Path: "file.txt",
},
}
for i, info := range infos {
infos[i] = Must(store.FileInfo().Save(info)).(*model.FileInfo)
}
if result := <-store.FileInfo().GetForPost(postId); result.Err != nil {
t.Fatal(result.Err)
} else if returned := result.Data.([]*model.FileInfo); len(returned) != 2 {
t.Fatal("should've returned exactly 2 file infos")
}
}
func TestFileInfoAttachToPost(t *testing.T) {
Setup()
userId := model.NewId()
postId := model.NewId()
info1 := Must(store.FileInfo().Save(&model.FileInfo{
CreatorId: userId,
Path: "file.txt",
})).(*model.FileInfo)
if len(info1.PostId) != 0 {
t.Fatal("file shouldn't have a PostId")
}
if result := <-store.FileInfo().AttachToPost(info1.Id, postId); result.Err != nil {
t.Fatal(result.Err)
} else {
info1 = Must(store.FileInfo().Get(info1.Id)).(*model.FileInfo)
}
if len(info1.PostId) == 0 {
t.Fatal("file should now have a PostId")
}
info2 := Must(store.FileInfo().Save(&model.FileInfo{
CreatorId: userId,
Path: "file.txt",
})).(*model.FileInfo)
if result := <-store.FileInfo().AttachToPost(info2.Id, postId); result.Err != nil {
t.Fatal(result.Err)
} else {
info2 = Must(store.FileInfo().Get(info2.Id)).(*model.FileInfo)
}
if result := <-store.FileInfo().GetForPost(postId); result.Err != nil {
t.Fatal(result.Err)
} else if infos := result.Data.([]*model.FileInfo); len(infos) != 2 {
t.Fatal("should've returned exactly 2 file infos")
}
}
func TestFileInfoDeleteForPost(t *testing.T) {
Setup()
userId := model.NewId()
postId := model.NewId()
infos := []*model.FileInfo{
{
PostId: postId,
CreatorId: userId,
Path: "file.txt",
},
{
PostId: postId,
CreatorId: userId,
Path: "file.txt",
},
{
PostId: postId,
CreatorId: userId,
Path: "file.txt",
DeleteAt: 123,
},
{
PostId: model.NewId(),
CreatorId: userId,
Path: "file.txt",
},
}
for i, info := range infos {
infos[i] = Must(store.FileInfo().Save(info)).(*model.FileInfo)
}
if result := <-store.FileInfo().DeleteForPost(postId); result.Err != nil {
t.Fatal(result.Err)
}
if infos := Must(store.FileInfo().GetForPost(postId)).([]*model.FileInfo); len(infos) != 0 {
t.Fatal("shouldn't have returned any file infos")
}
}

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

@@ -32,6 +32,7 @@ func NewSqlPostStore(sqlStore *SqlStore) PostStore {
table.ColMap("Hashtags").SetMaxSize(1000)
table.ColMap("Props").SetMaxSize(8000)
table.ColMap("Filenames").SetMaxSize(4000)
table.ColMap("FileIds").SetMaxSize(150)
}
return s
@@ -94,42 +95,39 @@ func (s SqlPostStore) Save(post *model.Post) StoreChannel {
return storeChannel
}
func (s SqlPostStore) Update(oldPost *model.Post, newMessage string, newHashtags string) StoreChannel {
func (s SqlPostStore) Update(newPost *model.Post, oldPost *model.Post) StoreChannel {
storeChannel := make(StoreChannel, 1)
go func() {
result := StoreResult{}
editPost := *oldPost
editPost.Message = newMessage
editPost.UpdateAt = model.GetMillis()
editPost.Hashtags = newHashtags
newPost.UpdateAt = model.GetMillis()
oldPost.DeleteAt = editPost.UpdateAt
oldPost.UpdateAt = editPost.UpdateAt
oldPost.DeleteAt = newPost.UpdateAt
oldPost.UpdateAt = newPost.UpdateAt
oldPost.OriginalId = oldPost.Id
oldPost.Id = model.NewId()
if result.Err = editPost.IsValid(); result.Err != nil {
if result.Err = newPost.IsValid(); result.Err != nil {
storeChannel <- result
close(storeChannel)
return
}
if _, err := s.GetMaster().Update(&editPost); err != nil {
result.Err = model.NewLocAppError("SqlPostStore.Update", "store.sql_post.update.app_error", nil, "id="+editPost.Id+", "+err.Error())
if _, err := s.GetMaster().Update(newPost); err != nil {
result.Err = model.NewLocAppError("SqlPostStore.Update", "store.sql_post.update.app_error", nil, "id="+newPost.Id+", "+err.Error())
} else {
time := model.GetMillis()
s.GetMaster().Exec("UPDATE Channels SET LastPostAt = :LastPostAt WHERE Id = :ChannelId", map[string]interface{}{"LastPostAt": time, "ChannelId": editPost.ChannelId})
s.GetMaster().Exec("UPDATE Channels SET LastPostAt = :LastPostAt WHERE Id = :ChannelId", map[string]interface{}{"LastPostAt": time, "ChannelId": newPost.ChannelId})
if len(editPost.RootId) > 0 {
s.GetMaster().Exec("UPDATE Posts SET UpdateAt = :UpdateAt WHERE Id = :RootId", map[string]interface{}{"UpdateAt": time, "RootId": editPost.RootId})
if len(newPost.RootId) > 0 {
s.GetMaster().Exec("UPDATE Posts SET UpdateAt = :UpdateAt WHERE Id = :RootId", map[string]interface{}{"UpdateAt": time, "RootId": newPost.RootId})
}
// mark the old post as deleted
s.GetMaster().Insert(oldPost)
result.Data = &editPost
result.Data = newPost
}
storeChannel <- result
@@ -972,7 +970,7 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustH
}
if mustHaveFile {
query += " AND Posts.Filenames != '[]'"
query += " AND (Posts.FileIds != '[]' OR Posts.Filenames != '[]')"
}
if mustHaveHashtag {

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

@@ -87,45 +87,73 @@ func TestPostStoreUpdate(t *testing.T) {
ro1 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id]
ro2 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o2.Id]
ro6 := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id]
ro3 := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id]
if ro1.Message != o1.Message {
t.Fatal("Failed to save/get")
}
msg := o1.Message + "BBBBBBBBBB"
if result := <-store.Post().Update(ro1, msg, ""); result.Err != nil {
o1a := &model.Post{}
*o1a = *ro1
o1a.Message = ro1.Message + "BBBBBBBBBB"
if result := <-store.Post().Update(o1a, ro1); result.Err != nil {
t.Fatal(result.Err)
}
msg2 := o2.Message + "DDDDDDD"
if result := <-store.Post().Update(ro2, msg2, ""); result.Err != nil {
ro1a := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id]
if ro1a.Message != o1a.Message {
t.Fatal("Failed to update/get")
}
o2a := &model.Post{}
*o2a = *ro2
o2a.Message = ro2.Message + "DDDDDDD"
if result := <-store.Post().Update(o2a, ro2); result.Err != nil {
t.Fatal(result.Err)
}
msg3 := o3.Message + "WWWWWWW"
if result := <-store.Post().Update(ro6, msg3, "#hashtag"); result.Err != nil {
ro2a := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o2.Id]
if ro2a.Message != o2a.Message {
t.Fatal("Failed to update/get")
}
o3a := &model.Post{}
*o3a = *ro3
o3a.Message = ro3.Message + "WWWWWWW"
if result := <-store.Post().Update(o3a, ro3); result.Err != nil {
t.Fatal(result.Err)
}
ro3 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o1.Id]
ro3a := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id]
if ro3.Message != msg {
if ro3a.Message != o3a.Message && ro3a.Hashtags != o3a.Hashtags {
t.Fatal("Failed to update/get")
}
ro4 := (<-store.Post().Get(o1.Id)).Data.(*model.PostList).Posts[o2.Id]
o4 := Must(store.Post().Save(&model.Post{
ChannelId: model.NewId(),
UserId: model.NewId(),
Message: model.NewId(),
Filenames: []string{"test"},
})).(*model.Post)
if ro4.Message != msg2 {
t.Fatal("Failed to update/get")
ro4 := (<-store.Post().Get(o4.Id)).Data.(*model.PostList).Posts[o4.Id]
o4a := &model.Post{}
*o4a = *ro4
o4a.Filenames = []string{}
o4a.FileIds = []string{model.NewId()}
if result := <-store.Post().Update(o4a, ro4); result.Err != nil {
t.Fatal(result.Err)
}
ro5 := (<-store.Post().Get(o3.Id)).Data.(*model.PostList).Posts[o3.Id]
if ro5.Message != msg3 && ro5.Hashtags != "#hashtag" {
t.Fatal("Failed to update/get")
if ro4a := Must(store.Post().Get(o4.Id)).(*model.PostList).Posts[o4.Id]; len(ro4a.Filenames) != 0 {
t.Fatal("Failed to clear Filenames")
} else if len(ro4a.FileIds) != 1 {
t.Fatal("Failed to set FileIds")
}
}
func TestPostStoreDelete(t *testing.T) {

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

@@ -81,6 +81,7 @@ type SqlStore struct {
recovery PasswordRecoveryStore
emoji EmojiStore
status StatusStore
fileInfo FileInfoStore
SchemaVersion string
}
@@ -129,6 +130,7 @@ func NewSqlStore() Store {
sqlStore.recovery = NewSqlPasswordRecoveryStore(sqlStore)
sqlStore.emoji = NewSqlEmojiStore(sqlStore)
sqlStore.status = NewSqlStatusStore(sqlStore)
sqlStore.fileInfo = NewSqlFileInfoStore(sqlStore)
err := sqlStore.master.CreateTablesIfNotExists()
if err != nil {
@@ -155,6 +157,7 @@ func NewSqlStore() Store {
sqlStore.recovery.(*SqlPasswordRecoveryStore).CreateIndexesIfNotExists()
sqlStore.emoji.(*SqlEmojiStore).CreateIndexesIfNotExists()
sqlStore.status.(*SqlStatusStore).CreateIndexesIfNotExists()
sqlStore.fileInfo.(*SqlFileInfoStore).CreateIndexesIfNotExists()
sqlStore.preference.(*SqlPreferenceStore).DeleteUnusedFeatures()
@@ -643,6 +646,10 @@ func (ss SqlStore) Status() StatusStore {
return ss.status
}
func (ss SqlStore) FileInfo() FileInfoStore {
return ss.fileInfo
}
func (ss SqlStore) DropAllTables() {
ss.master.TruncateTables()
}

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

@@ -181,7 +181,6 @@ func UpgradeDatabaseToVersion33(sqlStore *SqlStore) {
func UpgradeDatabaseToVersion34(sqlStore *SqlStore) {
if shouldPerformUpgrade(sqlStore, VERSION_3_3_0, VERSION_3_4_0) {
sqlStore.CreateColumnIfNotExists("Status", "Manual", "BOOLEAN", "BOOLEAN", "0")
sqlStore.CreateColumnIfNotExists("Status", "ActiveChannel", "varchar(26)", "varchar(26)", "")
@@ -199,6 +198,9 @@ func UpgradeDatabaseToVersion35(sqlStore *SqlStore) {
sqlStore.GetMaster().Exec("UPDATE ChannelMembers SET Roles = 'channel_user' WHERE Roles = ''")
sqlStore.GetMaster().Exec("UPDATE ChannelMembers SET Roles = 'channel_user channel_admin' WHERE Roles = 'admin'")
// The rest of the migration from Filenames -> FileIds is done lazily in api.GetFileInfosForPost
sqlStore.CreateColumnIfNotExists("Posts", "FileIds", "varchar(150)", "varchar(150)", "[]")
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// UNCOMMENT WHEN WE DO RELEASE
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

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

@@ -45,6 +45,7 @@ type Store interface {
PasswordRecovery() PasswordRecoveryStore
Emoji() EmojiStore
Status() StatusStore
FileInfo() FileInfoStore
MarkSystemRanUnitTests()
Close()
DropAllTables()
@@ -86,11 +87,13 @@ type ChannelStore interface {
GetMoreChannels(teamId string, userId string) StoreChannel
GetChannelCounts(teamId string, userId string) StoreChannel
GetAll(teamId string) StoreChannel
GetForPost(postId string) StoreChannel
SaveMember(member *model.ChannelMember) StoreChannel
UpdateMember(member *model.ChannelMember) StoreChannel
GetMembers(channelId string) StoreChannel
GetMember(channelId string, userId string) StoreChannel
GetMemberForPost(postId string, userId string) StoreChannel
GetMemberCount(channelId string) StoreChannel
RemoveMember(channelId string, userId string) StoreChannel
PermanentDeleteMembersByUser(userId string) StoreChannel
@@ -104,7 +107,7 @@ type ChannelStore interface {
type PostStore interface {
Save(post *model.Post) StoreChannel
Update(post *model.Post, newMessage string, newHashtags string) StoreChannel
Update(newPost *model.Post, oldPost *model.Post) StoreChannel
Get(id string) StoreChannel
Delete(postId string, time int64) StoreChannel
PermanentDeleteByUser(userId string) StoreChannel
@@ -277,3 +280,12 @@ type StatusStore interface {
GetTotalActiveUsersCount() StoreChannel
UpdateLastActivityAt(userId string, lastActivityAt int64) StoreChannel
}
type FileInfoStore interface {
Save(info *model.FileInfo) StoreChannel
Get(id string) StoreChannel
GetByPath(path string) StoreChannel
GetForPost(postId string) StoreChannel
AttachToPost(fileId string, postId string) StoreChannel
DeleteForPost(postId string) StoreChannel
}

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

@@ -34,3 +34,17 @@ func FileExistsInConfigFolder(filename string) bool {
}
return false
}
func RemoveDuplicatesFromStringArray(arr []string) []string {
result := make([]string, 0, len(arr))
seen := make(map[string]bool)
for _, item := range arr {
if !seen[item] {
result = append(result, item)
seen[item] = true
}
}
return result
}

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

@@ -28,3 +28,19 @@ func TestStringArrayIntersection(t *testing.T) {
t.Fatal("should be 1")
}
}
func TestRemoveDuplicatesFromStringArray(t *testing.T) {
a := []string{
"a",
"b",
"a",
"a",
"b",
"c",
"a",
}
if len(RemoveDuplicatesFromStringArray(a)) != 3 {
t.Fatal("should be 3")
}
}

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

@@ -294,11 +294,11 @@ export function showGetPostLinkModal(post) {
});
}
export function showGetPublicLinkModal(filename) {
export function showGetPublicLinkModal(fileId) {
AppDispatcher.handleViewAction({
type: ActionTypes.TOGGLE_GET_PUBLIC_LINK_MODAL,
value: true,
filename
fileId
});
}
@@ -388,7 +388,6 @@ export function sendEphemeralPost(message, channelId) {
type: Constants.POST_TYPE_EPHEMERAL,
create_at: timestamp,
update_at: timestamp,
filenames: [],
props: {}
};

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

@@ -113,10 +113,14 @@ export default class Client {
return `${this.url}${this.urlVersion}/users`;
}
getFilesRoute() {
getTeamFilesRoute() {
return `${this.url}${this.urlVersion}/teams/${this.getTeamId()}/files`;
}
getFileRoute(fileId) {
return `${this.url}${this.urlVersion}/files/${fileId}`;
}
getOAuthRoute() {
return `${this.url}${this.urlVersion}/oauth`;
}
@@ -1520,34 +1524,20 @@ export default class Client {
end(this.handleResponse.bind(this, 'getFlaggedPosts', success, error));
}
getFileInfosForPost(channelId, postId, success, error) {
request.
get(`${this.getChannelNeededRoute(channelId)}/posts/${postId}/get_file_infos`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getFileInfosForPost', success, error));
}
// Routes for Files
getFileInfo(filename, success, error) {
request.
get(`${this.getFilesRoute()}/get_info${filename}`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getFileInfo', success, error));
}
getPublicLink(filename, success, error) {
const data = {
filename
};
request.
post(`${this.getFilesRoute()}/get_public_link`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
send(data).
end(this.handleResponse.bind(this, 'getPublicLink', success, error));
}
uploadFile(file, filename, channelId, clientId, success, error) {
return request.
post(`${this.getFilesRoute()}/upload`).
post(`${this.getTeamFilesRoute()}/upload`).
set(this.defaultHeaders).
attach('files', file, filename).
field('channel_id', channelId).
@@ -1556,6 +1546,51 @@ export default class Client {
end(this.handleResponse.bind(this, 'uploadFile', success, error));
}
getFile(fileId, success, error) {
request.
get(`${this.getFileRoute(fileId)}/get`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getFile', success, error));
}
getFileThumbnail(fileId, success, error) {
request.
get(`${this.getFileRoute(fileId)}/get_thumbnail`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getFileThumbnail', success, error));
}
getFilePreview(fileId, success, error) {
request.
get(`${this.getFileRoute(fileId)}/get`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getFilePreview', success, error));
}
getFileInfo(fileId, success, error) {
request.
get(`${this.getFileRoute(fileId)}/get_info`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getFileInfo', success, error));
}
getPublicLink(fileId, success, error) {
request.
get(`${this.getFileRoute(fileId)}/get_public_link`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
end(this.handleResponse.bind(this, 'getPublicLink', success, error));
}
// Routes for OAuth
registerOAuthApp(app, success, error) {

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

@@ -76,10 +76,8 @@ export default class AudioVideoPreview extends React.Component {
if (!this.state.canPlay) {
return (
<FileInfoPreview
filename={this.props.filename}
fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo}
formatMessage={this.props.formatMessage}
fileUrl={this.props.fileUrl}
/>
);
}
@@ -94,7 +92,7 @@ export default class AudioVideoPreview extends React.Component {
// add a key to the video to prevent React from using an old video source while a new one is loading
return (
<video
key={this.props.filename}
key={this.props.fileInfo.id}
ref='video'
style={{maxHeight: this.props.maxHeight}}
data-setup='{}'
@@ -112,9 +110,7 @@ export default class AudioVideoPreview extends React.Component {
}
AudioVideoPreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired,
formatMessage: React.PropTypes.func.isRequired
fileUrl: React.PropTypes.string.isRequired,
maxHeight: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]).isRequired
};

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

@@ -38,7 +38,7 @@ export default class CodePreview extends React.Component {
}
updateStateFromProps(props) {
var usedLanguage = SyntaxHighlighting.getLanguageFromFilename(props.filename);
const usedLanguage = SyntaxHighlighting.getLanguageFromFileExtension(props.fileInfo.extension);
if (!usedLanguage || props.fileInfo.size > Constants.CODE_PREVIEW_MAX_FILE_SIZE) {
this.setState({code: '', lang: '', loading: false, success: false});
@@ -64,8 +64,8 @@ export default class CodePreview extends React.Component {
this.setState({loading: false, success: false});
}
static support(filename) {
return Boolean(SyntaxHighlighting.getLanguageFromFilename(filename));
static supports(fileInfo) {
return Boolean(SyntaxHighlighting.getLanguageFromFileExtension(fileInfo.extension));
}
render() {
@@ -83,10 +83,8 @@ export default class CodePreview extends React.Component {
if (!this.state.success) {
return (
<FileInfoPreview
filename={this.props.filename}
fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo}
formatMessage={this.props.formatMessage}
fileUrl={this.props.fileUrl}
/>
);
}
@@ -106,12 +104,10 @@ export default class CodePreview extends React.Component {
const highlighted = SyntaxHighlighting.highlight(this.state.lang, this.state.code);
const fileName = this.props.filename.substring(this.props.filename.lastIndexOf('/') + 1, this.props.filename.length);
return (
<div className='post-code'>
<span className='post-code__language'>
{`${fileName} - ${language}`}
{`${this.props.fileInfo.name} - ${language}`}
</span>
<code className='hljs'>
<table>
@@ -129,8 +125,6 @@ export default class CodePreview extends React.Component {
}
CodePreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
formatMessage: React.PropTypes.func.isRequired
fileUrl: React.PropTypes.string.isRequired
};

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

@@ -55,7 +55,7 @@ export default class CreateComment extends React.Component {
this.state = {
messageText: draft.message,
uploadsInProgress: draft.uploadsInProgress,
previews: draft.previews,
fileInfos: draft.fileInfos,
submitting: false,
ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
showPostDeletedModal: false
@@ -99,10 +99,10 @@ export default class CreateComment extends React.Component {
}
const post = {};
post.filenames = [];
post.file_ids = [];
post.message = this.state.messageText;
if (post.message.trim().length === 0 && this.state.previews.length === 0) {
if (post.message.trim().length === 0 && this.state.fileInfos.length === 0) {
return;
}
@@ -126,7 +126,7 @@ export default class CreateComment extends React.Component {
post.channel_id = this.props.channelId;
post.root_id = this.props.rootId;
post.parent_id = this.props.rootId;
post.filenames = this.state.previews;
post.file_ids = this.state.fileInfos.map((info) => info.id);
const time = Utils.getTimestamp();
post.pending_post_id = `${userId}:${time}`;
post.user_id = userId;
@@ -163,7 +163,7 @@ export default class CreateComment extends React.Component {
messageText: '',
submitting: false,
postError: null,
previews: [],
fileInfos: [],
serverError: null
});
}
@@ -245,7 +245,7 @@ export default class CreateComment extends React.Component {
this.focusTextbox();
}
handleFileUploadComplete(filenames, clientIds) {
handleFileUploadComplete(fileInfos, clientIds) {
const draft = PostStore.getCommentDraft(this.props.rootId);
// remove each finished file from uploads
@@ -257,10 +257,10 @@ export default class CreateComment extends React.Component {
}
}
draft.previews = draft.previews.concat(filenames);
draft.fileInfos = draft.fileInfos.concat(fileInfos);
PostStore.storeCommentDraft(this.props.rootId, draft);
this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
this.setState({uploadsInProgress: draft.uploadsInProgress, fileInfos: draft.fileInfos});
}
handleUploadError(err, clientId) {
@@ -281,11 +281,11 @@ export default class CreateComment extends React.Component {
}
removePreview(id) {
const previews = this.state.previews;
const fileInfos = this.state.fileInfos;
const uploadsInProgress = this.state.uploadsInProgress;
// id can either be the path of an uploaded file or the client id of an in progress upload
let index = previews.indexOf(id);
// id can either be the id of an uploaded file or the client id of an in progress upload
let index = fileInfos.findIndex((info) => info.id === id);
if (index === -1) {
index = uploadsInProgress.indexOf(id);
@@ -294,26 +294,26 @@ export default class CreateComment extends React.Component {
this.refs.fileUpload.getWrappedInstance().cancelUpload(id);
}
} else {
previews.splice(index, 1);
fileInfos.splice(index, 1);
}
const draft = PostStore.getCommentDraft(this.props.rootId);
draft.previews = previews;
draft.fileInfos = fileInfos;
draft.uploadsInProgress = uploadsInProgress;
PostStore.storeCommentDraft(this.props.rootId, draft);
this.setState({previews, uploadsInProgress});
this.setState({fileInfos, uploadsInProgress});
}
componentWillReceiveProps(newProps) {
if (newProps.rootId !== this.props.rootId) {
const draft = PostStore.getCommentDraft(newProps.rootId);
this.setState({messageText: draft.message, uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
this.setState({messageText: draft.message, uploadsInProgress: draft.uploadsInProgress, fileInfos: draft.fileInfos});
}
}
getFileCount() {
return this.state.previews.length + this.state.uploadsInProgress.length;
return this.state.fileInfos.length + this.state.uploadsInProgress.length;
}
focusTextbox() {
@@ -350,10 +350,10 @@ export default class CreateComment extends React.Component {
}
let preview = null;
if (this.state.previews.length > 0 || this.state.uploadsInProgress.length > 0) {
if (this.state.fileInfos.length > 0 || this.state.uploadsInProgress.length > 0) {
preview = (
<FilePreview
files={this.state.previews}
fileInfos={this.state.fileInfos}
onRemove={this.removePreview}
uploadsInProgress={this.state.uploadsInProgress}
/>

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

@@ -67,7 +67,7 @@ export default class CreatePost extends React.Component {
channelId: ChannelStore.getCurrentId(),
messageText: draft.messageText,
uploadsInProgress: draft.uploadsInProgress,
previews: draft.previews,
fileInfos: draft.fileInfos,
submitting: false,
initialText: draft.messageText,
ctrlSend: PreferenceStore.getBool(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
@@ -79,14 +79,14 @@ export default class CreatePost extends React.Component {
getCurrentDraft() {
const draft = PostStore.getCurrentDraft();
const safeDraft = {previews: [], messageText: '', uploadsInProgress: []};
const safeDraft = {fileInfos: [], messageText: '', uploadsInProgress: []};
if (draft) {
if (draft.message) {
safeDraft.messageText = draft.message;
}
if (draft.previews) {
safeDraft.previews = draft.previews;
if (draft.fileInfos) {
safeDraft.fileInfos = draft.fileInfos;
}
if (draft.uploadsInProgress) {
safeDraft.uploadsInProgress = draft.uploadsInProgress;
@@ -104,10 +104,10 @@ export default class CreatePost extends React.Component {
}
const post = {};
post.filenames = [];
post.file_ids = [];
post.message = this.state.messageText;
if (post.message.trim().length === 0 && this.state.previews.length === 0) {
if (post.message.trim().length === 0 && this.state.fileInfos.length === 0) {
return;
}
@@ -122,7 +122,7 @@ export default class CreatePost extends React.Component {
if (post.message.indexOf('/') === 0) {
PostStore.storeDraft(this.state.channelId, null);
this.setState({messageText: '', postError: null, previews: []});
this.setState({messageText: '', postError: null, fileInfos: []});
ChannelActions.executeCommand(
this.state.channelId,
@@ -153,7 +153,7 @@ export default class CreatePost extends React.Component {
sendMessage(post) {
post.channel_id = this.state.channelId;
post.filenames = this.state.previews;
post.file_ids = this.state.fileInfos.map((info) => info.id);
const time = Utils.getTimestamp();
const userId = UserStore.getCurrentId();
@@ -163,7 +163,7 @@ export default class CreatePost extends React.Component {
post.parent_id = this.state.parentId;
GlobalActions.emitUserPostedEvent(post);
this.setState({messageText: '', submitting: false, postError: null, previews: [], serverError: null});
this.setState({messageText: '', submitting: false, postError: null, fileInfos: [], serverError: null});
Client.createPost(post,
(data) => {
@@ -236,7 +236,7 @@ export default class CreatePost extends React.Component {
this.focusTextbox();
}
handleFileUploadComplete(filenames, clientIds, channelId) {
handleFileUploadComplete(fileInfos, clientIds, channelId) {
const draft = PostStore.getDraft(channelId);
// remove each finished file from uploads
@@ -248,11 +248,11 @@ export default class CreatePost extends React.Component {
}
}
draft.previews = draft.previews.concat(filenames);
draft.fileInfos = draft.fileInfos.concat(fileInfos);
PostStore.storeDraft(channelId, draft);
if (channelId === this.state.channelId) {
this.setState({uploadsInProgress: draft.uploadsInProgress, previews: draft.previews});
this.setState({uploadsInProgress: draft.uploadsInProgress, fileInfos: draft.fileInfos});
}
}
@@ -282,11 +282,11 @@ export default class CreatePost extends React.Component {
}
removePreview(id) {
const previews = Object.assign([], this.state.previews);
const fileInfos = Object.assign([], this.state.fileInfos);
const uploadsInProgress = this.state.uploadsInProgress;
// id can either be the path of an uploaded file or the client id of an in progress upload
let index = previews.indexOf(id);
// id can either be the id of an uploaded file or the client id of an in progress upload
let index = fileInfos.findIndex((info) => info.id === id);
if (index === -1) {
index = uploadsInProgress.indexOf(id);
@@ -295,15 +295,15 @@ export default class CreatePost extends React.Component {
this.refs.fileUpload.getWrappedInstance().cancelUpload(id);
}
} else {
previews.splice(index, 1);
fileInfos.splice(index, 1);
}
const draft = PostStore.getCurrentDraft();
draft.previews = previews;
draft.fileInfos = fileInfos;
draft.uploadsInProgress = uploadsInProgress;
PostStore.storeCurrentDraft(draft);
this.setState({previews, uploadsInProgress});
this.setState({fileInfos, uploadsInProgress});
}
componentWillMount() {
@@ -336,6 +336,7 @@ export default class CreatePost extends React.Component {
PreferenceStore.removeChangeListener(this.onPreferenceChange);
document.removeEventListener('keydown', this.showShortcuts);
}
showShortcuts(e) {
if ((e.ctrlKey || e.metaKey) && e.keyCode === Constants.KeyCodes.FORWARD_SLASH) {
e.preventDefault();
@@ -359,7 +360,7 @@ export default class CreatePost extends React.Component {
if (this.state.channelId !== channelId) {
const draft = this.getCurrentDraft();
this.setState({channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, serverError: null, postError: null, previews: draft.previews, uploadsInProgress: draft.uploadsInProgress});
this.setState({channelId, messageText: draft.messageText, initialText: draft.messageText, submitting: false, serverError: null, postError: null, fileInfos: draft.fileInfos, uploadsInProgress: draft.uploadsInProgress});
}
}
@@ -374,11 +375,11 @@ export default class CreatePost extends React.Component {
getFileCount(channelId) {
if (channelId === this.state.channelId) {
return this.state.previews.length + this.state.uploadsInProgress.length;
return this.state.fileInfos.length + this.state.uploadsInProgress.length;
}
const draft = PostStore.getDraft(channelId);
return draft.previews.length + draft.uploadsInProgress.length;
return draft.fileInfos.length + draft.uploadsInProgress.length;
}
handleKeyDown(e) {
@@ -474,10 +475,10 @@ export default class CreatePost extends React.Component {
}
let preview = null;
if (this.state.previews.length > 0 || this.state.uploadsInProgress.length > 0) {
if (this.state.fileInfos.length > 0 || this.state.uploadsInProgress.length > 0) {
preview = (
<FilePreview
files={this.state.previews}
fileInfos={this.state.fileInfos}
onRemove={this.removePreview}
uploadsInProgress={this.state.uploadsInProgress}
/>

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

@@ -1,204 +1,111 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import $ from 'jquery';
import ReactDOM from 'react-dom';
import * as utils from 'utils/utils.jsx';
import Client from 'client/web_client.jsx';
import Constants from 'utils/constants.jsx';
import FileStore from 'stores/file_store.jsx';
import * as Utils from 'utils/utils.jsx';
import {intlShape, injectIntl, defineMessages} from 'react-intl';
import {Tooltip, OverlayTrigger} from 'react-bootstrap';
const holders = defineMessages({
download: {
id: 'file_attachment.download',
defaultMessage: 'Download'
}
});
import React from 'react';
class FileAttachment extends React.Component {
export default class FileAttachment extends React.Component {
constructor(props) {
super(props);
this.loadFiles = this.loadFiles.bind(this);
this.addBackgroundImage = this.addBackgroundImage.bind(this);
this.onAttachmentClick = this.onAttachmentClick.bind(this);
this.canSetState = false;
this.state = {fileSize: -1};
this.state = {
loaded: Utils.getFileType(props.fileInfo.extension) !== 'image'
};
}
componentDidMount() {
this.loadFiles();
}
componentWillReceiveProps(nextProps) {
if (nextProps.fileInfo.id !== this.props.fileInfo.id) {
this.setState({
loaded: Utils.getFileType(nextProps.fileInfo.extension) !== 'image'
});
}
}
componentDidUpdate(prevProps) {
if (this.props.filename !== prevProps.filename) {
if (!this.state.loaded && this.props.fileInfo.id !== prevProps.fileInfo.id) {
this.loadFiles();
}
}
loadFiles() {
this.canSetState = true;
const fileInfo = this.props.fileInfo;
const fileType = Utils.getFileType(fileInfo.extension);
var filename = this.props.filename;
if (fileType === 'image') {
const thumbnailUrl = FileStore.getFileThumbnailUrl(fileInfo.id);
if (filename) {
var fileInfo = this.getFileInfoFromName(filename);
var type = utils.getFileType(fileInfo.ext);
if (type === 'image') {
var self = this; // Need this reference since we use the given "this"
$('<img/>').attr('src', fileInfo.path + '_thumb.jpg').on('load', (function loadWrapper(path, name) {
return function loader() {
$(this).remove();
if (name in self.refs) {
var imgDiv = ReactDOM.findDOMNode(self.refs[name]);
$(imgDiv).removeClass('post-image__load');
$(imgDiv).addClass('post-image');
var width = this.width || $(this).width();
var height = this.height || $(this).height();
if (width < Constants.THUMBNAIL_WIDTH &&
height < Constants.THUMBNAIL_HEIGHT) {
$(imgDiv).addClass('small');
} else {
$(imgDiv).addClass('normal');
}
self.addBackgroundImage(name, path);
}
};
}(fileInfo.path, filename)));
}
const img = new Image();
img.onload = () => {
this.setState({loaded: true});
};
img.load(thumbnailUrl);
}
}
componentWillUnmount() {
// keep track of when this component is mounted so that we can asynchronously change state without worrying about whether or not we're mounted
this.canSetState = false;
}
shouldComponentUpdate(nextProps, nextState) {
if (!utils.areObjectsEqual(nextProps, this.props)) {
return true;
}
// the only time this object should update is when it receives an updated file size which we can usually handle without re-rendering
if (nextState.fileSize !== this.state.fileSize) {
if (this.refs.fileSize) {
// update the UI element to display the file size without re-rendering the whole component
ReactDOM.findDOMNode(this.refs.fileSize).innerHTML = utils.fileSizeToString(nextState.fileSize);
return false;
}
// we can't find the element that should hold the file size so we must not have rendered yet
return true;
}
return true;
}
getFileInfoFromName(name) {
var fileInfo = utils.splitFileLocation(name);
fileInfo.path = Client.getFilesRoute() + '/get' + fileInfo.path;
return fileInfo;
}
addBackgroundImage(name, path) {
var fileUrl = path;
if (name in this.refs) {
if (!path) {
fileUrl = this.getFileInfoFromName(name).path;
}
var imgDiv = ReactDOM.findDOMNode(this.refs[name]);
var re1 = new RegExp(' ', 'g');
var re2 = new RegExp('\\(', 'g');
var re3 = new RegExp('\\)', 'g');
var url = fileUrl.replace(re1, '%20').replace(re2, '%28').replace(re3, '%29');
$(imgDiv).css('background-image', 'url(' + url + '_thumb.jpg)');
}
}
removeBackgroundImage(name) {
if (name in this.refs) {
$(ReactDOM.findDOMNode(this.refs[name])).css('background-image', 'initial');
}
}
onAttachmentClick(e) {
e.preventDefault();
this.props.handleImageClick(this.props.index);
}
render() {
var filename = this.props.filename;
const fileInfo = this.props.fileInfo;
const fileName = fileInfo.name;
const fileUrl = FileStore.getFileUrl(fileInfo.id);
var fileInfo = utils.splitFileLocation(filename);
var fileUrl = utils.getFileUrl(filename);
var type = utils.getFileType(fileInfo.ext);
let thumbnail;
if (this.state.loaded) {
const type = Utils.getFileType(fileInfo.extension);
var thumbnail;
if (type === 'image') {
thumbnail = (
<div
ref={filename}
className='post-image__load'
/>
);
} else {
thumbnail = <div className={'file-icon ' + utils.getIconClassName(type)}/>;
}
if (type === 'image') {
let className = 'post-image';
var fileSizeString = '';
if (this.state.fileSize < 0) {
Client.getFileInfo(
filename,
(data) => {
if (this.canSetState) {
this.setState({fileSize: parseInt(data.size, 10)});
}
},
() => {
// Do nothing
if (fileInfo.width < Constants.THUMBNAIL_WIDTH && fileInfo.height < Constants.THUMBNAIL_HEIGHT) {
className += ' small';
} else {
className += ' normal';
}
);
thumbnail = (
<div
className={className}
style={{
backgroundImage: `url(${FileStore.getFileThumbnailUrl(fileInfo.id)})`
}}
/>
);
} else {
thumbnail = <div className={'file-icon ' + Utils.getIconClassName(type)}/>;
}
} else {
fileSizeString = utils.fileSizeToString(this.state.fileSize);
thumbnail = <div className='post-image__load'/>;
}
var filenameString = decodeURIComponent(utils.getFileName(filename));
var trimmedFilename;
if (filenameString.length > 35) {
trimmedFilename = filenameString.substring(0, Math.min(35, filenameString.length)) + '...';
let trimmedFilename;
if (fileName.length > 35) {
trimmedFilename = fileName.substring(0, Math.min(35, fileName.length)) + '...';
} else {
trimmedFilename = filenameString;
trimmedFilename = fileName;
}
var filenameOverlay = (
<OverlayTrigger
delayShow={1000}
placement='top'
overlay={<Tooltip id='file-name__tooltip'>{this.props.intl.formatMessage(holders.download) + ' "' + filenameString + '"'}</Tooltip>}
>
<a
href={fileUrl}
download={filenameString}
className='post-image__name'
target='_blank'
rel='noopener noreferrer'
>
{trimmedFilename}
</a>
</OverlayTrigger>
);
let filenameOverlay;
if (this.props.compactDisplay) {
filenameOverlay = (
<OverlayTrigger
delayShow={1000}
placement='top'
overlay={<Tooltip id='file-name__tooltip'>{filenameString}</Tooltip>}
overlay={<Tooltip id='file-name__tooltip'>{fileName}</Tooltip>}
>
<a
href='#'
@@ -214,13 +121,28 @@ class FileAttachment extends React.Component {
</a>
</OverlayTrigger>
);
} else {
filenameOverlay = (
<OverlayTrigger
delayShow={1000}
placement='top'
overlay={<Tooltip id='file-name__tooltip'>{Utils.localizeMessage('file_attachment.download', 'Download') + ' "' + fileName + '"'}</Tooltip>}
>
<a
href={fileUrl}
download={fileName}
className='post-image__name'
target='_blank'
rel='noopener noreferrer'
>
{trimmedFilename}
</a>
</OverlayTrigger>
);
}
return (
<div
className='post-image__column'
key={filename}
>
<div className='post-image__column'>
<a
className='post-image__thumbnail'
href='#'
@@ -233,17 +155,15 @@ class FileAttachment extends React.Component {
<div>
<a
href={fileUrl}
download={filenameString}
download={fileName}
className='post-image__download'
target='_blank'
rel='noopener noreferrer'
>
<span
className='fa fa-download'
/>
<span className='fa fa-download'/>
</a>
<span className='post-image__type'>{fileInfo.ext.toUpperCase()}</span>
<span className='post-image__size'>{fileSizeString}</span>
<span className='post-image__type'>{fileInfo.extension.toUpperCase()}</span>
<span className='post-image__size'>{Utils.fileSizeToString(fileInfo.size)}</span>
</div>
</div>
</div>
@@ -252,10 +172,7 @@ class FileAttachment extends React.Component {
}
FileAttachment.propTypes = {
intl: intlShape.isRequired,
// a list of file pathes displayed by the parent FileAttachmentList
filename: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
// the index of this attachment preview in the parent FileAttachmentList
index: React.PropTypes.number.isRequired,
@@ -264,6 +181,4 @@ FileAttachment.propTypes = {
handleImageClick: React.PropTypes.func,
compactDisplay: React.PropTypes.bool
};
export default injectIntl(FileAttachment);
};

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

@@ -13,25 +13,34 @@ export default class FileAttachmentList extends React.Component {
this.handleImageClick = this.handleImageClick.bind(this);
this.state = {showPreviewModal: false, startImgId: 0};
this.state = {showPreviewModal: false, startImgIndex: 0};
}
handleImageClick(indexClicked) {
this.setState({showPreviewModal: true, startImgId: indexClicked});
}
render() {
var filenames = this.props.filenames;
var postFiles = [];
for (var i = 0; i < filenames.length && i < Constants.MAX_DISPLAY_FILES; i++) {
postFiles.push(
<FileAttachment
key={'file_attachment_' + i}
filename={filenames[i]}
index={i}
handleImageClick={this.handleImageClick}
compactDisplay={this.props.compactDisplay}
/>
);
handleImageClick(indexClicked) {
this.setState({showPreviewModal: true, startImgIndex: indexClicked});
}
render() {
const postFiles = [];
if (this.props.fileInfos && this.props.fileInfos.length > 0) {
for (let i = 0; i < Math.min(this.props.fileInfos.length, Constants.MAX_DISPLAY_FILES); i++) {
const fileInfo = this.props.fileInfos[i];
postFiles.push(
<FileAttachment
key={fileInfo.id}
fileInfo={this.props.fileInfos[i]}
index={i}
handleImageClick={this.handleImageClick}
compactDisplay={this.props.compactDisplay}
/>
);
}
} else if (this.props.fileCount > 0) {
for (let i = 0; i < Math.min(this.props.fileCount, Constants.MAX_DISPLAY_FILES); i++) {
// Add a placeholder to avoid pop-in once we get the file infos for this post
postFiles.push(<div className='post-image__column post-image__column--placeholder'/>);
}
}
return (
@@ -42,10 +51,8 @@ export default class FileAttachmentList extends React.Component {
<ViewImageModal
show={this.state.showPreviewModal}
onModalDismissed={() => this.setState({showPreviewModal: false})}
channelId={this.props.channelId}
userId={this.props.userId}
startId={this.state.startImgId}
filenames={filenames}
startId={this.state.startImgIndex}
fileInfos={this.props.fileInfos}
/>
</div>
);
@@ -53,15 +60,7 @@ export default class FileAttachmentList extends React.Component {
}
FileAttachmentList.propTypes = {
// a list of file pathes displayed by this
filenames: React.PropTypes.arrayOf(React.PropTypes.string).isRequired,
// the channel that this is part of
channelId: React.PropTypes.string,
// the user that owns the post that this is attached to
userId: React.PropTypes.string,
fileCount: React.PropTypes.number.isRequired,
fileInfos: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
compactDisplay: React.PropTypes.bool
};

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

@@ -0,0 +1,90 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import * as AsyncClient from 'utils/async_client.jsx';
import FileStore from 'stores/file_store.jsx';
import FileAttachmentList from './file_attachment_list.jsx';
export default class FileAttachmentListContainer extends React.Component {
static propTypes = {
post: React.PropTypes.object.isRequired,
compactDisplay: React.PropTypes.bool.isRequired
}
constructor(props) {
super(props);
this.handleFileChange = this.handleFileChange.bind(this);
this.state = {
fileInfos: FileStore.getInfosForPost(props.post.id)
};
}
componentDidMount() {
FileStore.addChangeListener(this.handleFileChange);
if (this.props.post.id && !FileStore.hasInfosForPost(this.props.post.id)) {
AsyncClient.getFileInfosForPost(this.props.post.channel_id, this.props.post.id);
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.post.id !== this.props.post.id) {
this.setState({
fileInfos: FileStore.getInfosForPost(nextProps.post.id)
});
if (nextProps.post.id && !FileStore.hasInfosForPost(nextProps.post.id)) {
AsyncClient.getFileInfosForPost(nextProps.post.channel_id, nextProps.post.id);
}
}
}
shouldComponentUpdate(nextProps, nextState) {
if (this.props.post.id !== nextProps.post.id) {
return true;
}
if (this.props.compactDisplay !== nextProps.compactDisplay) {
return true;
}
// fileInfos are treated as immutable by the FileStore
if (nextState.fileInfos !== this.state.fileInfos) {
return true;
}
return false;
}
handleFileChange() {
this.setState({
fileInfos: FileStore.getInfosForPost(this.props.post.id)
});
}
componentWillUnmount() {
FileStore.removeChangeListener(this.handleFileChange);
}
render() {
let fileCount = 0;
if (this.props.post.file_ids) {
fileCount = this.props.post.file_ids.length;
} else if (this.props.post.filenames) {
fileCount = this.props.post.filenames.length;
}
return (
<FileAttachmentList
fileCount={fileCount}
fileInfos={this.state.fileInfos}
compactDisplay={this.props.compactDisplay}
/>
);
}
}

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

@@ -1,59 +1,59 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import * as Utils from 'utils/utils.jsx';
import {defineMessages} from 'react-intl';
import React from 'react';
import {Link} from 'react-router/es6';
export default class FileInfoPreview extends React.Component {
shouldComponentUpdate(nextProps) {
if (nextProps.fileUrl !== this.props.fileUrl) {
return true;
}
const holders = defineMessages({
type: {
id: 'file_info_preview.type',
defaultMessage: 'File type '
},
size: {
id: 'file_info_preview.size',
defaultMessage: 'Size '
}
});
if (!Utils.areObjectsEqual(nextProps.fileInfo, this.props.fileInfo)) {
return true;
}
export default function FileInfoPreview({filename, fileUrl, fileInfo, formatMessage}) {
// non-image files include a section providing details about the file
const infoParts = [];
if (fileInfo.extension !== '') {
infoParts.push(formatMessage(holders.type) + fileInfo.extension.toUpperCase());
return false;
}
infoParts.push(formatMessage(holders.size) + Utils.fileSizeToString(fileInfo.size));
render() {
const fileInfo = this.props.fileInfo;
const fileUrl = this.props.fileUrl;
const infoString = infoParts.join(', ');
// non-image files include a section providing details about the file
const infoParts = [];
const name = decodeURIComponent(Utils.getFileName(filename));
if (fileInfo.extension !== '') {
infoParts.push(Utils.localizeMessage('file_info_preview.type', 'File type ') + fileInfo.extension.toUpperCase());
}
return (
<div className='file-details__container'>
<Link
className={'file-details__preview'}
to={fileUrl}
target='_blank'
rel='noopener noreferrer'
>
<span className='file-details__preview-helper'/>
<img src={Utils.getPreviewImagePath(filename)}/>
</Link>
<div className='file-details'>
<div className='file-details__name'>{name}</div>
<div className='file-details__info'>{infoString}</div>
infoParts.push(Utils.localizeMessage('file_info_preview.size', 'Size ') + Utils.fileSizeToString(fileInfo.size));
const infoString = infoParts.join(', ');
return (
<div className='file-details__container'>
<a
className={'file-details__preview'}
to={fileUrl}
target='_blank'
rel='noopener noreferrer'
>
<span className='file-details__preview-helper'/>
<img src={Utils.getFileIconPath(fileInfo)}/>
</a>
<div className='file-details'>
<div className='file-details__name'>{fileInfo.name}</div>
<div className='file-details__info'>{infoString}</div>
</div>
</div>
</div>
);
);
}
}
FileInfoPreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
formatMessage: React.PropTypes.func.isRequired
fileUrl: React.PropTypes.string.isRequired
};

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

@@ -1,6 +1,7 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import FileStore from 'stores/file_store.jsx';
import ReactDOM from 'react-dom';
import * as Utils from 'utils/utils.jsx';
@@ -21,63 +22,43 @@ export default class FilePreview extends React.Component {
}
}
handleRemove(e) {
var previewDiv = e.target.parentNode.parentNode;
if (previewDiv.hasAttribute('data-filename')) {
this.props.onRemove(previewDiv.getAttribute('data-filename'));
} else if (previewDiv.hasAttribute('data-client-id')) {
this.props.onRemove(previewDiv.getAttribute('data-client-id'));
}
handleRemove(id) {
this.props.onRemove(id);
}
render() {
var previews = [];
this.props.files.forEach((fullFilename) => {
var filename = fullFilename;
var originalFilename = filename;
var filenameSplit = filename.split('.');
var ext = filenameSplit[filenameSplit.length - 1];
var type = Utils.getFileType(ext);
filename = Utils.getFileUrl(filename);
this.props.fileInfos.forEach((info) => {
const type = Utils.getFileType(info.extension);
let className = 'file-preview';
let previewImage;
if (type === 'image') {
previews.push(
<div
key={filename}
className='file-preview'
data-filename={originalFilename}
>
<img
className='file-preview__image'
src={filename}
/>
<a
className='file-preview__remove'
onClick={this.handleRemove}
>
<i className='fa fa-remove'/>
</a>
</div>
previewImage = (
<img
className='file-preview__image'
src={FileStore.getFileUrl(info.id)}
/>
);
} else {
previews.push(
<div
key={filename}
className='file-preview custom-file'
data-filename={originalFilename}
>
<div className={'file-icon ' + Utils.getIconClassName(type)}/>
<a
className='file-preview__remove'
onClick={this.handleRemove}
>
<i className='fa fa-remove'/>
</a>
</div>
);
className += ' custom-file';
previewImage = <div className={'file-icon ' + Utils.getIconClassName(type)}/>;
}
previews.push(
<div
key={info.id}
className={className}
>
{previewImage}
<a
className='file-preview__remove'
onClick={this.handleRemove.bind(this, info.id)}
>
<i className='fa fa-remove'/>
</a>
</div>
);
});
this.props.uploadsInProgress.forEach((clientId) => {
@@ -94,7 +75,7 @@ export default class FilePreview extends React.Component {
/>
<a
className='file-preview__remove'
onClick={this.handleRemove}
onClick={this.handleRemove.bind(this, clientId)}
>
<i className='fa fa-remove'/>
</a>
@@ -111,11 +92,11 @@ export default class FilePreview extends React.Component {
}
FilePreview.defaultProps = {
files: [],
fileInfos: [],
uploadsInProgress: []
};
FilePreview.propTypes = {
onRemove: React.PropTypes.func.isRequired,
files: React.PropTypes.array,
fileInfos: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
uploadsInProgress: React.PropTypes.array
};

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

@@ -49,13 +49,12 @@ class FileUpload extends React.Component {
this.keyUpload = this.keyUpload.bind(this);
this.state = {
maxFileSize: global.window.mm_config.MaxFileSize,
requests: {}
};
}
fileUploadSuccess(channelId, data) {
this.props.onFileUpload(data.filenames, data.client_ids, channelId);
this.props.onFileUpload(data.file_infos, data.client_ids, channelId);
const requests = Object.assign({}, this.state.requests);
for (var j = 0; j < data.client_ids.length; j++) {
@@ -81,7 +80,7 @@ class FileUpload extends React.Component {
const tooLargeFiles = [];
for (let i = 0; i < files.length && numUploads < uploadsRemaining; i++) {
if (files[i].size > this.state.maxFileSize) {
if (files[i].size > global.mm_config.MaxFileSize) {
tooLargeFiles.push(files[i]);
continue;
}
@@ -112,9 +111,9 @@ class FileUpload extends React.Component {
} else if (tooLargeFiles.length > 1) {
var tooLargeFilenames = tooLargeFiles.map((file) => file.name).join(', ');
this.props.onUploadError(formatMessage(holders.filesAbove, {max: (this.state.maxFileSize / 1048576), filenames: tooLargeFilenames}));
this.props.onUploadError(formatMessage(holders.filesAbove, {max: (global.mm_config.MaxFileSize / 1048576), filenames: tooLargeFilenames}));
} else if (tooLargeFiles.length > 0) {
this.props.onUploadError(formatMessage(holders.fileAbove, {max: (this.state.maxFileSize / 1048576), filename: tooLargeFiles[0].name}));
this.props.onUploadError(formatMessage(holders.fileAbove, {max: (global.mm_config.MaxFileSize / 1048576), filename: tooLargeFiles[0].name}));
}
}

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

@@ -23,7 +23,7 @@ export default class GetPublicLinkModal extends React.Component {
this.state = {
show: false,
filename: '',
fileId: '',
link: ''
};
}
@@ -34,7 +34,7 @@ export default class GetPublicLinkModal extends React.Component {
componentDidUpdate(prevProps, prevState) {
if (this.state.show && !prevState.show) {
AsyncClient.getPublicLink(decodeURIComponent(this.state.filename), this.handlePublicLink);
AsyncClient.getPublicLink(this.state.fileId, this.handlePublicLink);
}
}
@@ -51,7 +51,7 @@ export default class GetPublicLinkModal extends React.Component {
handleToggle(value, args) {
this.setState({
show: value,
filename: args.filename,
fileId: args.fileId,
link: ''
});
}

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

@@ -3,8 +3,6 @@
import FileInfoPreview from './file_info_preview.jsx';
import * as Utils from 'utils/utils.jsx';
import loadingGif from 'images/load.gif';
import React from 'react';
@@ -109,18 +107,8 @@ export default class PDFPreview extends React.Component {
}
}
static support(filename) {
const fileInfo = Utils.splitFileLocation(filename);
const ext = fileInfo.ext;
if (!ext) {
return false;
}
if (ext === 'pdf') {
return true;
}
return false;
static supports(fileInfo) {
return fileInfo.extension === 'pdf';
}
render() {
@@ -138,10 +126,8 @@ export default class PDFPreview extends React.Component {
if (!this.state.success) {
return (
<FileInfoPreview
filename={this.props.filename}
fileUrl={this.props.fileUrl}
fileInfo={this.props.fileInfo}
formatMessage={this.props.formatMessage}
fileUrl={this.props.fileUrl}
/>
);
}
@@ -185,8 +171,6 @@ export default class PDFPreview extends React.Component {
}
PDFPreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
formatMessage: React.PropTypes.func.isRequired
fileUrl: React.PropTypes.string.isRequired
};

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

@@ -0,0 +1,88 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import * as AsyncClient from 'utils/async_client.jsx';
import FileStore from 'stores/file_store.jsx';
import * as Utils from 'utils/utils.jsx';
export default class CommentedOnFilesMessageContainer extends React.Component {
static propTypes = {
parentPostChannelId: React.PropTypes.string.isRequired,
parentPostId: React.PropTypes.string.isRequired
}
constructor(props) {
super(props);
this.handleFileChange = this.handleFileChange.bind(this);
this.state = {
fileInfos: FileStore.getInfosForPost(this.props.parentPostId)
};
}
componentDidMount() {
FileStore.addChangeListener(this.handleFileChange);
if (!FileStore.hasInfosForPost(this.props.parentPostId)) {
AsyncClient.getFileInfosForPost(this.props.parentPostChannelId, this.props.parentPostId);
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.parentPostId !== this.props.parentPostId) {
this.setState({
fileInfos: FileStore.getInfosForPost(this.props.parentPostId)
});
if (!FileStore.hasInfosForPost(this.props.parentPostId)) {
AsyncClient.getFileInfosForPost(this.props.parentPostChannelId, this.props.parentPostId);
}
}
}
shouldComponentUpdate(nextProps, nextState) {
if (nextProps.parentPostId !== this.props.parentPostId) {
return true;
}
if (nextProps.parentPostChannelId !== this.props.parentPostChannelId) {
return true;
}
// fileInfos are treated as immutable by the FileStore
if (nextState.fileInfos !== this.state.fileInfos) {
return true;
}
return false;
}
handleFileChange() {
this.setState({
fileInfos: FileStore.getInfosForPost(this.props.parentPostId)
});
}
componentWillUnmount() {
FileStore.removeChangeListener(this.handleFileChange);
}
render() {
let message = ' ';
if (this.state.fileInfos && this.state.fileInfos.length > 0) {
message = this.state.fileInfos[0].name;
if (this.state.fileInfos.length === 2) {
message += Utils.localizeMessage('post_body.plusOne', ' plus 1 other file');
} else if (this.state.fileInfos.length > 2) {
message += Utils.localizeMessage('post_body.plusMore', ' plus {count} other files').replace('{count}', (this.state.fileInfos.length - 1).toString());
}
}
return <span>{message}</span>;
}
}

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

@@ -1,11 +1,12 @@
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import FileAttachmentList from 'components/file_attachment_list.jsx';
import UserStore from 'stores/user_store.jsx';
import * as Utils from 'utils/utils.jsx';
import * as GlobalActions from 'actions/global_actions.jsx';
import Constants from 'utils/constants.jsx';
import CommentedOnFilesMessageContainer from './commented_on_files_message_container.jsx';
import FileAttachmentListContainer from 'components/file_attachment_list_container.jsx';
import PostBodyAdditionalContent from './post_body_additional_content.jsx';
import PostMessageContainer from './post_message_container.jsx';
import PendingPostOptions from './pending_post_options.jsx';
@@ -22,6 +23,7 @@ export default class PostBody extends React.Component {
this.removePost = this.removePost.bind(this);
}
shouldComponentUpdate(nextProps) {
if (nextProps.isCommentMention !== this.props.isCommentMention) {
return true;
@@ -56,7 +58,6 @@ export default class PostBody extends React.Component {
render() {
const post = this.props.post;
const filenames = this.props.post.filenames;
const parentPost = this.props.parentPost;
let comment = '';
@@ -94,14 +95,13 @@ export default class PostBody extends React.Component {
let message = '';
if (parentPost.message) {
message = Utils.replaceHtmlEntities(parentPost.message);
} else if (parentPost.filenames.length) {
message = parentPost.filenames[0].split('/').pop();
if (parentPost.filenames.length === 2) {
message += Utils.localizeMessage('post_body.plusOne', ' plus 1 other file');
} else if (parentPost.filenames.length > 2) {
message += Utils.localizeMessage('post_body.plusMore', ' plus {count} other files').replace('{count}', (parentPost.filenames.length - 1).toString());
}
} else if (parentPost.file_ids && parentPost.file_ids.length > 0) {
message = (
<CommentedOnFilesMessageContainer
parentPostChannelId={parentPost.channel_id}
parentPostId={parentPost.id}
/>
);
}
comment = (
@@ -140,14 +140,11 @@ export default class PostBody extends React.Component {
);
}
let fileAttachmentHolder = '';
if (filenames && filenames.length > 0) {
let fileAttachmentHolder = null;
if ((post.file_ids && post.file_ids.length > 0) || (post.filenames && post.filenames.length > 0)) {
fileAttachmentHolder = (
<FileAttachmentList
filenames={filenames}
channelId={post.channel_id}
userId={post.user_id}
<FileAttachmentListContainer
post={post}
compactDisplay={this.props.compactDisplay}
/>
);

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

@@ -2,7 +2,7 @@
// See License.txt for license information.
import UserProfile from './user_profile.jsx';
import FileAttachmentList from './file_attachment_list.jsx';
import FileAttachmentListContainer from './file_attachment_list_container.jsx';
import PendingPostOptions from 'components/post_view/components/pending_post_options.jsx';
import PostMessageContainer from 'components/post_view/components/post_message_container.jsx';
import ProfilePicture from 'components/profile_picture.jsx';
@@ -295,13 +295,11 @@ export default class RhsComment extends React.Component {
var dropdown = this.createDropdown();
var fileAttachment;
if (post.filenames && post.filenames.length > 0) {
let fileAttachment = null;
if (post.file_ids && post.file_ids.length > 0) {
fileAttachment = (
<FileAttachmentList
filenames={post.filenames}
channelId={post.channel_id}
userId={post.user_id}
<FileAttachmentListContainer
post={post}
compactDisplay={this.props.compactDisplay}
/>
);

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

@@ -4,7 +4,7 @@
import UserProfile from './user_profile.jsx';
import PostBodyAdditionalContent from 'components/post_view/components/post_body_additional_content.jsx';
import PostMessageContainer from 'components/post_view/components/post_message_container.jsx';
import FileAttachmentList from './file_attachment_list.jsx';
import FileAttachmentListContainer from './file_attachment_list_container.jsx';
import ProfilePicture from 'components/profile_picture.jsx';
import ChannelStore from 'stores/channel_store.jsx';
@@ -242,13 +242,11 @@ export default class RhsRootPost extends React.Component {
);
}
var fileAttachment;
if (post.filenames && post.filenames.length > 0) {
let fileAttachment = null;
if (post.file_ids && post.file_ids.length > 0) {
fileAttachment = (
<FileAttachmentList
filenames={post.filenames}
channelId={post.channel_id}
userId={post.user_id}
<FileAttachmentListContainer
post={post}
compactDisplay={this.props.compactDisplay}
/>
);

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

@@ -11,7 +11,6 @@ import * as GlobalActions from 'actions/global_actions.jsx';
import FileStore from 'stores/file_store.jsx';
import * as AsyncClient from 'utils/async_client.jsx';
import * as Utils from 'utils/utils.jsx';
import Constants from 'utils/constants.jsx';
@@ -19,19 +18,11 @@ const KeyCodes = Constants.KeyCodes;
import $ from 'jquery';
import React from 'react';
import {intlShape, injectIntl, defineMessages} from 'react-intl';
import {Modal} from 'react-bootstrap';
import loadingGif from 'images/load.gif';
const holders = defineMessages({
loading: {
id: 'view_image.loading',
defaultMessage: 'Loading '
}
});
class ViewImageModal extends React.Component {
export default class ViewImageModal extends React.Component {
constructor(props) {
super(props);
@@ -45,18 +36,15 @@ class ViewImageModal extends React.Component {
this.onModalShown = this.onModalShown.bind(this);
this.onModalHidden = this.onModalHidden.bind(this);
this.onFileStoreChange = this.onFileStoreChange.bind(this);
this.handleGetPublicLink = this.handleGetPublicLink.bind(this);
this.onMouseEnterImage = this.onMouseEnterImage.bind(this);
this.onMouseLeaveImage = this.onMouseLeaveImage.bind(this);
this.state = {
imgId: this.props.startId,
fileInfo: null,
imgHeight: '100%',
loaded: Utils.fillArray(false, this.props.filenames.length),
progress: Utils.fillArray(0, this.props.filenames.length),
loaded: Utils.fillArray(false, this.props.fileInfos.length),
progress: Utils.fillArray(0, this.props.fileInfos.length),
showFooter: false
};
}
@@ -66,7 +54,7 @@ class ViewImageModal extends React.Component {
e.stopPropagation();
}
let id = this.state.imgId + 1;
if (id > this.props.filenames.length - 1) {
if (id > this.props.fileInfos.length - 1) {
id = 0;
}
this.showImage(id);
@@ -78,7 +66,7 @@ class ViewImageModal extends React.Component {
}
let id = this.state.imgId - 1;
if (id < 0) {
id = this.props.filenames.length - 1;
id = this.props.fileInfos.length - 1;
}
this.showImage(id);
}
@@ -95,8 +83,6 @@ class ViewImageModal extends React.Component {
$(window).on('keyup', this.handleKeyPress);
this.showImage(nextProps.startId);
FileStore.addChangeListener(this.onFileStoreChange);
}
onModalHidden() {
@@ -105,8 +91,6 @@ class ViewImageModal extends React.Component {
if (this.refs.video) {
this.refs.video.stop();
}
FileStore.removeChangeListener(this.onFileStoreChange);
}
componentWillReceiveProps(nextProps) {
@@ -116,64 +100,36 @@ class ViewImageModal extends React.Component {
this.onModalHidden();
}
if (!Utils.areObjectsEqual(this.props.filenames, nextProps.filenames)) {
if (this.props.fileInfos !== nextProps.fileInfos) {
this.setState({
loaded: Utils.fillArray(false, nextProps.filenames.length),
progress: Utils.fillArray(0, nextProps.filenames.length)
loaded: Utils.fillArray(false, nextProps.fileInfos.length),
progress: Utils.fillArray(0, nextProps.fileInfos.length)
});
}
}
onFileStoreChange(filename) {
const id = this.props.filenames.indexOf(filename);
if (id !== -1) {
if (id === this.state.imgId) {
this.setState({
fileInfo: FileStore.getInfo(filename)
});
}
if (!this.state.loaded[id]) {
this.loadImage(id, filename);
}
}
}
showImage(id) {
this.setState({imgId: id});
const imgHeight = $(window).height() - 100;
this.setState({imgHeight});
const filename = this.props.filenames[id];
if (!FileStore.hasInfo(filename)) {
// the image will actually be loaded once we know what we need to load
AsyncClient.getFileInfo(filename);
return;
}
this.setState({
fileInfo: FileStore.getInfo(filename)
});
if (!this.state.loaded[id]) {
this.loadImage(id, filename);
this.loadImage(id);
}
}
loadImage(id, filename) {
const fileInfo = FileStore.getInfo(filename);
loadImage(index) {
const fileInfo = this.props.fileInfos[index];
const fileType = Utils.getFileType(fileInfo.extension);
if (fileType === 'image') {
let previewUrl;
if (fileInfo.has_image_preview) {
previewUrl = Utils.getPreviewImagePath(filename);
previewUrl = FileStore.getFilePreviewUrl(fileInfo.id);
} else {
// some images (eg animated gifs) just show the file itself and not a preview
previewUrl = Utils.getFileUrl(filename);
previewUrl = FileStore.getFileUrl(fileInfo.id);
}
const img = new Image();
@@ -181,19 +137,19 @@ class ViewImageModal extends React.Component {
previewUrl,
() => {
const progress = this.state.progress;
progress[id] = img.completedPercentage;
progress[index] = img.completedPercentage;
this.setState({progress});
}
);
img.onload = () => {
const loaded = this.state.loaded;
loaded[id] = true;
loaded[index] = true;
this.setState({loaded});
};
} else {
// there's nothing to load for non-image files
var loaded = this.state.loaded;
loaded[id] = true;
loaded[index] = true;
this.setState({loaded});
}
}
@@ -201,7 +157,7 @@ class ViewImageModal extends React.Component {
handleGetPublicLink() {
this.props.onModalDismissed();
GlobalActions.showGetPublicLinkModal(this.props.filenames[this.state.imgId]);
GlobalActions.showGetPublicLinkModal(this.props.fileInfos[this.state.imgId].id);
}
onMouseEnterImage() {
@@ -213,63 +169,52 @@ class ViewImageModal extends React.Component {
}
render() {
if (this.props.filenames.length < 1 || this.props.filenames.length - 1 < this.state.imgId) {
return <div/>;
if (this.props.fileInfos.length < 1 || this.props.fileInfos.length - 1 < this.state.imgId) {
return null;
}
const filename = this.props.filenames[this.state.imgId];
const fileUrl = Utils.getFileUrl(filename);
const fileInfo = this.props.fileInfos[this.state.imgId];
const fileUrl = FileStore.getFileUrl(fileInfo.id);
var content;
let content;
if (this.state.loaded[this.state.imgId]) {
// this.state.fileInfo is for the current image and we shoudl have it before we load the image
const fileInfo = this.state.fileInfo;
const fileType = Utils.getFileType(fileInfo.extension);
if (fileType === 'image') {
content = (
<ImagePreview
filename={filename}
fileUrl={fileUrl}
fileInfo={fileInfo}
fileUrl={fileUrl}
maxHeight={this.state.imgHeight}
/>
);
} else if (fileType === 'video' || fileType === 'audio') {
content = (
<AudioVideoPreview
filename={filename}
fileInfo={fileInfo}
fileUrl={fileUrl}
fileInfo={this.state.fileInfo}
maxHeight={this.state.imgHeight}
formatMessage={this.props.intl.formatMessage}
/>
);
} else if (PDFPreview.support(filename)) {
} else if (PDFPreview.supports(fileInfo)) {
content = (
<PDFPreview
filename={filename}
fileUrl={fileUrl}
fileInfo={fileInfo}
formatMessage={this.props.intl.formatMessage}
fileUrl={fileUrl}
/>
);
} else if (CodePreview.support(filename)) {
} else if (CodePreview.supports(fileInfo)) {
content = (
<CodePreview
filename={filename}
fileUrl={fileUrl}
fileInfo={fileInfo}
formatMessage={this.props.intl.formatMessage}
fileUrl={fileUrl}
/>
);
} else {
content = (
<FileInfoPreview
filename={filename}
fileUrl={fileUrl}
fileInfo={fileInfo}
formatMessage={this.props.intl.formatMessage}
fileUrl={fileUrl}
/>
);
}
@@ -280,14 +225,14 @@ class ViewImageModal extends React.Component {
content = (
<LoadingImagePreview
progress={progress}
loading={this.props.intl.formatMessage(holders.loading)}
loading={Utils.localizeMessage('view_image.loading', 'Loading ')}
/>
);
}
let leftArrow = null;
let rightArrow = null;
if (this.props.filenames.length > 1) {
if (this.props.fileInfos.length > 1) {
leftArrow = (
<a
ref='previewArrowLeft'
@@ -346,8 +291,8 @@ class ViewImageModal extends React.Component {
<ViewImagePopoverBar
show={this.state.showFooter}
fileId={this.state.imgId}
totalFiles={this.props.filenames.length}
filename={name}
totalFiles={this.props.fileInfos.length}
filename={fileInfo.name}
fileURL={fileUrl}
onGetPublicLink={this.handleGetPublicLink}
/>
@@ -363,19 +308,13 @@ class ViewImageModal extends React.Component {
ViewImageModal.defaultProps = {
show: false,
filenames: [],
channelId: '',
userId: '',
fileInfos: [],
startId: 0
};
ViewImageModal.propTypes = {
intl: intlShape.isRequired,
show: React.PropTypes.bool.isRequired,
onModalDismissed: React.PropTypes.func.isRequired,
filenames: React.PropTypes.array,
modalId: React.PropTypes.string,
channelId: React.PropTypes.string,
userId: React.PropTypes.string,
fileInfos: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
startId: React.PropTypes.number
};
@@ -405,10 +344,10 @@ LoadingImagePreview.propTypes = {
loading: React.PropTypes.string
};
function ImagePreview({filename, fileUrl, fileInfo, maxHeight}) {
function ImagePreview({fileInfo, fileUrl, maxHeight}) {
let previewUrl;
if (fileInfo.has_preview_image) {
previewUrl = Utils.getPreviewImagePath(filename);
previewUrl = FileStore.getFilePreviewUrl(fileInfo.id);
} else {
previewUrl = fileUrl;
}
@@ -429,10 +368,7 @@ function ImagePreview({filename, fileUrl, fileInfo, maxHeight}) {
}
ImagePreview.propTypes = {
filename: React.PropTypes.string.isRequired,
fileUrl: React.PropTypes.string.isRequired,
fileInfo: React.PropTypes.object.isRequired,
fileUrl: React.PropTypes.string.isRequired,
maxHeight: React.PropTypes.number.isRequired
};
export default injectIntl(ViewImageModal);

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

@@ -166,6 +166,10 @@
margin: 5px 10px 5px 0;
position: relative;
width: 240px;
&--placeholder {
visibility: hidden;
}
}
.post-image__load {

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

@@ -16,41 +16,58 @@ class FileStore extends EventEmitter {
this.handleEventPayload = this.handleEventPayload.bind(this);
this.dispatchToken = AppDispatcher.register(this.handleEventPayload);
this.fileInfo = new Map();
this.setMaxListeners(600);
this.fileInfosByPost = new Map();
}
addChangeListener(callback) {
this.on(CHANGE_EVENT, callback);
}
removeChangeListener(callback) {
this.removeListener(CHANGE_EVENT, callback);
}
emitChange(filename) {
this.emit(CHANGE_EVENT, filename);
emitChange() {
this.emit(CHANGE_EVENT);
}
hasInfo(filename) {
return this.fileInfo.has(filename);
hasInfosForPost(postId) {
return this.fileInfosByPost.has(postId);
}
getInfo(filename) {
return this.fileInfo.get(filename);
getInfosForPost(postId) {
return this.fileInfosByPost.get(postId);
}
setInfo(filename, info) {
this.fileInfo.set(filename, info);
saveInfos(postId, infos) {
this.fileInfosByPost.set(postId, infos);
}
getFileUrl(fileId) {
return `/api/v3/files/${fileId}/get`;
}
getFileThumbnailUrl(fileId) {
return `/api/v3/files/${fileId}/get_thumbnail`;
}
getFilePreviewUrl(fileId) {
return `/api/v3/files/${fileId}/get_preview`;
}
handleEventPayload(payload) {
const action = payload.action;
switch (action.type) {
case ActionTypes.RECEIVED_FILE_INFO:
this.setInfo(action.filename, action.info);
this.emitChange(action.filename);
case ActionTypes.RECEIVED_FILE_INFOS:
// This assumes that all received file infos are for a single post
this.saveInfos(action.postId, action.infos);
this.emitChange(action.postId);
break;
}
}
}
export default new FileStore();
export default new FileStore();

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

@@ -84,7 +84,7 @@ class NotificationStoreClass extends EventEmitter {
if (msgProps.image) {
body = username + Utils.localizeMessage('channel_loader.uploadedImage', ' uploaded an image');
} else if (msgProps.otherFile) {
body = Utils.localizeMessage('channel_loader.uploadedFile', ' uploaded a file');
body = username + Utils.localizeMessage('channel_loader.uploadedFile', ' uploaded a file');
} else {
body = username + Utils.localizeMessage('channel_loader.something', ' did something new');
}

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

@@ -224,7 +224,7 @@ class PostStoreClass extends EventEmitter {
} else if (combinedPosts.posts.hasOwnProperty(pid)) {
combinedPosts.posts[pid] = Object.assign({}, np, {
state: Constants.POST_DELETED,
filenames: []
fileIds: []
});
}
}
@@ -318,7 +318,7 @@ class PostStoreClass extends EventEmitter {
// make sure to copy the post so that component state changes work properly
postList.posts[post.id] = Object.assign({}, post, {
state: Constants.POST_DELETED,
filenames: []
fileIds: []
});
}
}
@@ -514,7 +514,7 @@ class PostStoreClass extends EventEmitter {
}
getEmptyDraft() {
return {message: '', uploadsInProgress: [], previews: []};
return {message: '', uploadsInProgress: [], fileInfos: []};
}
storeCurrentDraft(draft) {

248
webapp/tests/client_file.test.jsx Обычный файл
Просмотреть файл

@@ -0,0 +1,248 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import assert from 'assert';
import TestHelper from './test_helper.jsx';
const fs = require('fs');
describe('Client.File', function() {
this.timeout(100000);
before(function() {
// write a temporary file so that we have something to upload for testing
const buffer = new Buffer('R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=', 'base64');
const testGif = fs.openSync('test.gif', 'w+');
fs.writeFileSync(testGif, buffer);
});
after(function() {
fs.unlinkSync('test.gif');
});
it('uploadFile', function(done) {
TestHelper.initBasic(() => {
const clientId = TestHelper.generateId();
TestHelper.basicClient().uploadFile(
fs.createReadStream('test.gif'),
'test.gif',
TestHelper.basicChannel().id,
clientId,
function(resp) {
assert.equal(resp.file_infos.length, 1);
assert.equal(resp.client_ids.length, 1);
assert.equal(resp.client_ids[0], clientId);
done();
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('getFile', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().uploadFile(
fs.createReadStream('test.gif'),
'test.gif',
TestHelper.basicChannel().id,
'',
function(resp) {
TestHelper.basicClient().getFile(
resp.file_infos[0].id,
function() {
done();
},
function(err2) {
done(new Error(err2.message));
}
);
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('getFileThumbnail', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().uploadFile(
fs.createReadStream('test.gif'),
'test.gif',
TestHelper.basicChannel().id,
'',
function(resp) {
TestHelper.basicClient().getFileThumbnail(
resp.file_infos[0].id,
function() {
done();
},
function(err) {
done(new Error(err.message));
}
);
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('getFilePreview', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().uploadFile(
fs.createReadStream('test.gif'),
'test.gif',
TestHelper.basicChannel().id,
'',
function(resp) {
TestHelper.basicClient().getFilePreview(
resp.file_infos[0].id,
function() {
done();
},
function(err2) {
done(new Error(err2.message));
}
);
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('getFileInfo', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().uploadFile(
fs.createReadStream('test.gif'),
'test.gif',
TestHelper.basicChannel().id,
'',
function(resp) {
const fileId = resp.file_infos[0].id;
TestHelper.basicClient().getFileInfo(
fileId,
function(info) {
assert.equal(info.id, fileId);
assert.equal(info.name, 'test.gif');
done();
},
function(err2) {
done(new Error(err2.message));
}
);
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('getPublicLink', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().uploadFile(
fs.createReadStream('test.gif'),
'test.gif',
TestHelper.basicChannel().id,
'',
function(resp) {
const post = TestHelper.fakePost();
post.channel_id = TestHelper.basicChannel().id;
post.file_ids = resp.file_infos.map((info) => info.id);
TestHelper.basicClient().createPost(
post,
function(data) {
assert.deepEqual(data.file_ids, post.file_ids);
TestHelper.basicClient().getPublicLink(
post.file_ids[0],
function() {
done(new Error('public links should be disabled by default'));
// request.
// get(link).
// end(TestHelper.basicChannel().handleResponse.bind(
// this,
// 'getPublicLink',
// function() {
// done();
// },
// function(err4) {
// done(new Error(err4.message));
// }
// ));
},
function() {
done();
// done(new Error(err3.message));
}
);
},
function(err2) {
done(new Error(err2.message));
}
);
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('getFileInfosForPost', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().uploadFile(
fs.createReadStream('test.gif'),
'test.gif',
TestHelper.basicChannel().id,
'',
function(resp) {
const post = TestHelper.fakePost();
post.channel_id = TestHelper.basicChannel().id;
post.file_ids = resp.file_infos.map((info) => info.id);
TestHelper.basicClient().createPost(
post,
function(data) {
assert.deepEqual(data.file_ids, post.file_ids);
TestHelper.basicClient().getFileInfosForPost(
post.channel_id,
data.id,
function(files) {
assert.equal(files.length, 1);
assert.equal(files[0].id, resp.file_infos[0].id);
done();
},
function(err3) {
done(new Error(err3.message));
}
);
},
function(err2) {
done(new Error(err2.message));
}
);
},
function(err) {
done(new Error(err.message));
}
);
});
});
});

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

@@ -43,43 +43,5 @@ describe('Client.General', function() {
done();
});
});
it('File.getFileInfo', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().enableLogErrorsToConsole(false); // Disabling since this unit test causes an error
TestHelper.basicClient().getFileInfo(
`/${TestHelper.basicChannel().id}/${TestHelper.basicUser().id}/filename.txt`,
function(data) {
assert.equal(data.filename, 'filename.txt');
done();
},
function(err) {
done(new Error(err.message));
}
);
});
});
it('File.getPublicLink', function(done) {
TestHelper.initBasic(() => {
TestHelper.basicClient().enableLogErrorsToConsole(false); // Disabling since this unit test causes an error
var data = {};
data.channel_id = TestHelper.basicChannel().id;
data.user_id = TestHelper.basicUser().id;
data.filename = `/${TestHelper.basicChannel().id}/${TestHelper.basicUser().id}/filename.txt`;
TestHelper.basicClient().getPublicLink(
data,
function() {
done(new Error('not enabled'));
},
function(err) {
assert.equal(err.id, 'api.file.get_public_link.disabled.app_error');
done();
}
);
});
});
});

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

@@ -230,5 +230,7 @@ describe('Client.Posts', function() {
);
});
});
// getFileInfosForPost is tested in client_files.test.jsx
});

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

@@ -715,6 +715,32 @@ export function getPostsAfter(postId, offset, numPost, isPost) {
);
}
export function getFileInfosForPost(channelId, postId) {
const callName = 'getFileInfosForPost' + postId;
if (isCallInProgress(callName)) {
return;
}
Client.getFileInfosForPost(
channelId,
postId,
(data) => {
callTracker[callName] = 0;
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_FILE_INFOS,
postId,
infos: data
});
},
(err) => {
callTracker[callName] = 0;
dispatchError(err, 'getPostFile');
}
);
}
export function getMe() {
if (isCallInProgress('getMe')) {
return null;
@@ -923,34 +949,6 @@ export function getSuggestedCommands(command, suggestionId, component) {
);
}
export function getFileInfo(filename) {
const callName = 'getFileInfo' + filename;
if (isCallInProgress(callName)) {
return;
}
callTracker[callName] = utils.getTimestamp();
Client.getFileInfo(
filename,
(data) => {
callTracker[callName] = 0;
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_FILE_INFO,
filename,
info: data
});
},
(err) => {
callTracker[callName] = 0;
dispatchError(err, 'getFileInfo');
}
);
}
export function getStandardAnalytics(teamId) {
const callName = 'getStandardAnaytics' + teamId;
@@ -1432,8 +1430,8 @@ export function regenCommandToken(id) {
);
}
export function getPublicLink(filename, success, error) {
const callName = 'getPublicLink' + filename;
export function getPublicLink(fileId, success, error) {
const callName = 'getPublicLink' + fileId;
if (isCallInProgress(callName)) {
return;
@@ -1442,7 +1440,7 @@ export function getPublicLink(filename, success, error) {
callTracker[callName] = utils.getTimestamp();
Client.getPublicLink(
filename,
fileId,
(link) => {
callTracker[callName] = 0;

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

@@ -95,7 +95,7 @@ export const ActionTypes = keyMirror({
RECEIVED_PREFERENCE: null,
RECEIVED_PREFERENCES: null,
DELETED_PREFERENCES: null,
RECEIVED_FILE_INFO: null,
RECEIVED_FILE_INFOS: null,
RECEIVED_ANALYTICS: null,
RECEIVED_INCOMING_WEBHOOKS: null,

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

@@ -136,14 +136,9 @@ export function highlight(lang, code) {
return TextFormatting.sanitizeHtml(code);
}
export function getLanguageFromFilename(filename) {
const fileSplit = filename.split('.');
let ext = fileSplit.length > 1 ? fileSplit[fileSplit.length - 1] : '';
ext = ext.toLowerCase();
export function getLanguageFromFileExtension(extension) {
for (var key in HighlightedLanguages) {
if (HighlightedLanguages[key].extensions.find((x) => x === ext)) {
if (HighlightedLanguages[key].extensions.find((x) => x === extension)) {
return key;
}
}

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

@@ -413,8 +413,8 @@ export function getFileType(extin) {
return 'other';
}
export function getPreviewImagePathForFileType(fileTypeIn) {
var fileType = fileTypeIn.toLowerCase();
export function getFileIconPath(fileInfo) {
const fileType = getFileType(fileInfo.extension);
var icon;
if (fileType in Constants.ICON_FROM_TYPE) {
@@ -451,19 +451,6 @@ export function splitFileLocation(fileLocation) {
return {ext, name: filename, path: filePath};
}
export function getPreviewImagePath(filename) {
// Returns the path to a preview image that can be used to represent a file.
const fileInfo = splitFileLocation(filename);
const fileType = getFileType(fileInfo.ext);
if (fileType === 'image') {
return getFileUrl(fileInfo.path + '_preview.jpg');
}
// only images have proper previews, so just use a placeholder icon for non-images
return getPreviewImagePathForFileType(fileType);
}
export function toTitleCase(str) {
function doTitleCase(txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();