diff --git a/api/file.go b/api/file.go index 4339e610b5..d31abfb1d5 100644 --- a/api/file.go +++ b/api/file.go @@ -23,7 +23,6 @@ import ( "image/jpeg" "io" "io/ioutil" - "mime" "net/http" "net/url" "os" @@ -323,25 +322,22 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) { cchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, channelId, c.Session.UserId) path := "teams/" + c.Session.TeamId + "/channels/" + channelId + "/users/" + userId + "/" + filename - size := "" + var info *model.FileInfo - if s, ok := fileInfoCache.Get(path); ok { - size = s.(string) + if cached, ok := fileInfoCache.Get(path); ok { + info = cached.(*model.FileInfo) } else { - fileData := make(chan []byte) getFileAndForget(path, fileData) - f := <-fileData - - if f == nil { - c.Err = model.NewAppError("getFileInfo", "Could not find file.", "path="+path) - c.Err.StatusCode = http.StatusNotFound + newInfo, err := model.GetInfoForBytes(filename, <-fileData) + if err != nil { + c.Err = err return + } else { + fileInfoCache.Add(path, newInfo) + info = newInfo } - - size = strconv.Itoa(len(f)) - fileInfoCache.Add(path, size) } if !c.HasPermissionsToChannel(cchan, "getFileInfo") { @@ -350,19 +346,7 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=2592000, public") - var mimeType string - ext := filepath.Ext(filename) - if model.IsFileExtImage(ext) { - mimeType = model.GetImageMimeType(ext) - } else { - mimeType = mime.TypeByExtension(ext) - } - - result := make(map[string]string) - result["filename"] = filename - result["size"] = size - result["mime"] = mimeType - w.Write([]byte(model.MapToJson(result))) + w.Write([]byte(info.ToJson())) } func getFile(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/api/file_test.go b/api/file_test.go index b5501e4bdd..b3fbd2a270 100644 --- a/api/file_test.go +++ b/api/file_test.go @@ -152,7 +152,6 @@ func TestGetFile(t *testing.T) { channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) if utils.Cfg.FileSettings.DriverName != "" { - body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, err := writer.CreateFormFile("files", "test.png") @@ -204,9 +203,9 @@ func TestGetFile(t *testing.T) { if resp, downErr := Client.GetFileInfo(filenames[0]); downErr != nil { t.Fatal(downErr) } else { - m := resp.Data.(map[string]string) - if len(m["size"]) == 0 { - t.Fail() + info := resp.Data.(*model.FileInfo) + if info.Size == 0 { + t.Fatal("No file size returned") } } diff --git a/model/client.go b/model/client.go index d3f76817d7..b9b97dedca 100644 --- a/model/client.go +++ b/model/client.go @@ -717,7 +717,7 @@ func (c *Client) GetFileInfo(url string) (*Result, *AppError) { return nil, AppErrorFromJson(rp.Body) } else { return &Result{rp.Header.Get(HEADER_REQUEST_ID), - rp.Header.Get(HEADER_ETAG_SERVER), MapFromJson(rp.Body)}, nil + rp.Header.Get(HEADER_ETAG_SERVER), FileInfoFromJson(rp.Body)}, nil } } diff --git a/model/file_info.go b/model/file_info.go new file mode 100644 index 0000000000..741b4e55d7 --- /dev/null +++ b/model/file_info.go @@ -0,0 +1,72 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "bytes" + "encoding/json" + "image/gif" + "io" + "mime" + "path/filepath" +) + +type FileInfo struct { + Filename string `json:"filename"` + Size int `json:"size"` + Extension string `json:"extension"` + 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) + } + + 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, NewAppError("GetInfoForBytes", "Could not decode gif.", "filename="+filename) + } else { + hasPreviewImage = len(gifImage.Image) == 1 + } + } + + return &FileInfo{ + Filename: filename, + Size: size, + Extension: extension[1:], + MimeType: mimeType, + HasPreviewImage: hasPreviewImage, + }, nil +} + +func (info *FileInfo) ToJson() string { + b, err := json.Marshal(info) + if err != nil { + return "" + } else { + return string(b) + } +} + +func FileInfoFromJson(data io.Reader) *FileInfo { + decoder := json.NewDecoder(data) + + var info FileInfo + if err := decoder.Decode(&info); err != nil { + return nil + } else { + return &info + } +} diff --git a/model/file_info_test.go b/model/file_info_test.go new file mode 100644 index 0000000000..ecf0d509cc --- /dev/null +++ b/model/file_info_test.go @@ -0,0 +1,76 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/base64" + "io/ioutil" + "testing" +) + +func TestGetInfoForBytes(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.Size != 1000 { + t.Fatalf("Got incorrect size: %v", info.Size) + } else if info.Extension != "txt" { + t.Fatalf("Git incorrect file extension: %v", info.Extension) + } else if info.MimeType != "text/plain; charset=utf-8" { + t.Fatalf("Got incorrect mime type: %v", info.MimeType) + } else if info.HasPreviewImage { + t.Fatalf("Got HasPreviewImage = true for non-image file") + } + + if info, err := GetInfoForBytes("file.png", fakeFile); 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.Extension != "png" { + t.Fatalf("Git incorrect file extension: %v", info.Extension) + } else if info.MimeType != "image/png" { + t.Fatalf("Got incorrect mime type: %v", info.MimeType) + } else if !info.HasPreviewImage { + t.Fatalf("Got HasPreviewImage = false for image") + } + + // 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.Size != 35 { + t.Fatalf("Got incorrect size: %v", info.Size) + } else if info.Extension != "gif" { + t.Fatalf("Git incorrect file extension: %v", info.Extension) + } else if info.MimeType != "image/gif" { + t.Fatalf("Got incorrect mime type: %v", info.MimeType) + } else if !info.HasPreviewImage { + t.Fatalf("Got HasPreviewImage = false for static gif") + } + + animatedGifFile, err := ioutil.ReadFile("../web/static/images/testgif.gif") + if err != nil { + t.Fatalf("Failed to load testgif.gif: %v", err.Error()) + } + 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.Size != 38689 { + t.Fatalf("Got incorrect size: %v", info.Size) + } else if info.Extension != "gif" { + t.Fatalf("Git incorrect file extension: %v", info.Extension) + } else if info.MimeType != "image/gif" { + t.Fatalf("Got incorrect mime type: %v", info.MimeType) + } else if info.HasPreviewImage { + t.Fatalf("Got HasPreviewImage = true for animated gif") + } +} diff --git a/web/react/components/view_image.jsx b/web/react/components/view_image.jsx index 820f8fd8e8..7edf6283b2 100644 --- a/web/react/components/view_image.jsx +++ b/web/react/components/view_image.jsx @@ -1,9 +1,11 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import * as AsyncClient from '../utils/async_client.jsx'; import * as Client from '../utils/client.jsx'; import * as Utils from '../utils/utils.jsx'; import Constants from '../utils/constants.jsx'; +import FileStore from '../stores/file_store.jsx'; import ViewImagePopoverBar from './view_image_popover_bar.jsx'; const Modal = ReactBootstrap.Modal; const KeyCodes = Constants.KeyCodes; @@ -12,80 +14,90 @@ export default class ViewImageModal extends React.Component { constructor(props) { super(props); - this.canSetState = false; - + this.showImage = this.showImage.bind(this); this.loadImage = this.loadImage.bind(this); + this.handleNext = this.handleNext.bind(this); this.handlePrev = this.handlePrev.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); - this.getPublicLink = this.getPublicLink.bind(this); - this.getPreviewImagePath = this.getPreviewImagePath.bind(this); + this.onModalShown = this.onModalShown.bind(this); this.onModalHidden = this.onModalHidden.bind(this); + + this.onFileStoreChange = this.onFileStoreChange.bind(this); + + this.getPublicLink = this.getPublicLink.bind(this); + this.getPreviewImagePath = this.getPreviewImagePath.bind(this); this.onMouseEnterImage = this.onMouseEnterImage.bind(this); this.onMouseLeaveImage = this.onMouseLeaveImage.bind(this); - var loaded = []; - var progress = []; + const loaded = []; + const progress = []; for (var i = 0; i < this.props.filenames.length; i++) { loaded.push(false); progress.push(0); } + this.state = { imgId: this.props.startId, + fileInfo: new Map(), imgHeight: '100%', - loaded: loaded, - progress: progress, - images: {}, - fileSizes: {}, - fileMimes: {}, - showFooter: false, - isPlaying: {}, - isLoading: {} + loaded, + progress, + showFooter: false }; } + handleNext(e) { if (e) { e.stopPropagation(); } - var id = this.state.imgId + 1; + let id = this.state.imgId + 1; if (id > this.props.filenames.length - 1) { id = 0; } - this.setState({imgId: id}); - this.loadImage(id); + this.showImage(id); } + handlePrev(e) { if (e) { e.stopPropagation(); } - var id = this.state.imgId - 1; + let id = this.state.imgId - 1; if (id < 0) { id = this.props.filenames.length - 1; } - this.setState({imgId: id}); - this.loadImage(id); + this.showImage(id); } + handleKeyPress(e) { - if (!e || !this.props.show) { - return; - } else if (e.keyCode === KeyCodes.RIGHT) { + if (e.keyCode === KeyCodes.RIGHT) { this.handleNext(); } else if (e.keyCode === KeyCodes.LEFT) { this.handlePrev(); } } + onModalShown(nextProps) { - this.setState({imgId: nextProps.startId}); - this.loadImage(nextProps.startId); + $(window).on('keyup', this.handleKeyPress); + + this.showImage(nextProps.startId); + + FileStore.addChangeListener(this.onFileStoreChange); } + onModalHidden() { + $(window).off('keyup', this.handleKeyPress); + if (this.refs.video) { var video = ReactDOM.findDOMNode(this.refs.video); video.pause(); video.currentTime = 0; } + + FileStore.removeChangeListener(this.onFileStoreChange); } + componentWillReceiveProps(nextProps) { if (nextProps.show === true && this.props.show === false) { this.onModalShown(nextProps); @@ -93,31 +105,65 @@ export default class ViewImageModal extends React.Component { this.onModalHidden(); } } - loadImage(id) { - var imgHeight = $(window).height() - 100; + + onFileStoreChange(filename) { + const id = this.props.filenames.indexOf(filename); + + if (id !== -1 && !this.state.loaded[id]) { + const fileInfo = this.state.fileInfo; + fileInfo.set(filename, FileStore.getInfo(filename)); + this.setState({fileInfo}); + + this.loadImage(id, filename); + } + } + + showImage(id) { + this.setState({imgId: id}); + + const imgHeight = $(window).height() - 100; this.setState({imgHeight}); - var filename = this.props.filenames[id]; + const filename = this.props.filenames[id]; - var fileInfo = Utils.splitFileLocation(filename); - var fileType = Utils.getFileType(fileInfo.ext); + if (!FileStore.hasInfo(filename)) { + // the image will actually be loaded once we know what we need to load + AsyncClient.getFileInfo(filename); + return; + } + + if (!this.state.loaded[id]) { + this.loadImage(id, filename); + } + } + + loadImage(id, filename) { + const fileInfo = FileStore.getInfo(filename); + const fileType = Utils.getFileType(fileInfo.extension); if (fileType === 'image') { - var img = new Image(); - img.load(this.getPreviewImagePath(filename), - () => { - const progress = this.state.progress; - progress[id] = img.completedPercentage; - this.setState({progress}); - }); + let previewUrl; + if (fileInfo.has_image_preview) { + previewUrl = fileInfo.getPreviewImagePath(filename); + } else { + // some images (eg animated gifs) just show the file itself and not a preview + previewUrl = Utils.getFileUrl(filename); + } + + const img = new Image(); + img.load( + previewUrl, + () => { + const progress = this.state.progress; + progress[id] = img.completedPercentage; + this.setState({progress}); + } + ); img.onload = () => { const loaded = this.state.loaded; loaded[id] = true; this.setState({loaded}); }; - var images = this.state.images; - images[id] = img; - this.setState({images}); } else { // there's nothing to load for non-image files var loaded = this.state.loaded; @@ -125,169 +171,82 @@ export default class ViewImageModal extends React.Component { this.setState({loaded}); } } - playGif(e, filename, fileUrl) { - var isLoading = this.state.isLoading; - var isPlaying = this.state.isPlaying; - isLoading[filename] = fileUrl; - this.setState({isLoading}); - - var img = new Image(); - img.load(fileUrl); - img.onload = () => { - delete isLoading[filename]; - isPlaying[filename] = fileUrl; - this.setState({isPlaying, isLoading}); - }; - img.onError = () => { - delete isLoading[filename]; - this.setState({isLoading}); - }; - - e.stopPropagation(); - e.preventDefault(); - } - stopGif(e, filename) { - var isPlaying = this.state.isPlaying; - delete isPlaying[filename]; - this.setState({isPlaying}); - - e.stopPropagation(); - e.preventDefault(); - } - componentDidMount() { - $(window).on('keyup', this.handleKeyPress); - - // keep track of whether or not this component is mounted so we can safely set the state asynchronously - this.canSetState = true; - } - componentWillUnmount() { - this.canSetState = false; - $(window).off('keyup', this.handleKeyPress); - } getPublicLink() { var data = {}; data.channel_id = this.props.channelId; data.user_id = this.props.userId; data.filename = this.props.filenames[this.state.imgId]; - Client.getPublicLink(data, - function sucess(serverData) { + Client.getPublicLink( + data, + (serverData) => { if (Utils.isMobile()) { window.location.href = serverData.public_link; } else { window.open(serverData.public_link); } }, - function error() {} + () => {} ); } + getPreviewImagePath(filename) { // Returns the path to a preview image that can be used to represent a file. var fileInfo = Utils.splitFileLocation(filename); var fileType = Utils.getFileType(fileInfo.ext); if (fileType === 'image') { - if (filename in this.state.isPlaying) { - return this.state.isPlaying[filename]; - } - // This is a temporary patch to fix issue with old files using absolute paths if (fileInfo.path.indexOf('/api/v1/files/get') !== -1) { fileInfo.path = fileInfo.path.split('/api/v1/files/get')[1]; } fileInfo.path = Utils.getWindowLocationOrigin() + '/api/v1/files/get' + fileInfo.path; - return fileInfo.path + '_preview.jpg' + '?' + Utils.getSessionIndex(); + return fileInfo.path + '_preview.jpg?' + Utils.getSessionIndex(); } // only images have proper previews, so just use a placeholder icon for non-images return Utils.getPreviewImagePathForFileType(fileType); } + onMouseEnterImage() { this.setState({showFooter: true}); } + onMouseLeaveImage() { this.setState({showFooter: false}); } + render() { if (this.props.filenames.length < 1 || this.props.filenames.length - 1 < this.state.imgId) { return
; } - var filename = this.props.filenames[this.state.imgId]; - var fileUrl = Utils.getFileUrl(filename); - - var name = decodeURIComponent(Utils.getFileName(filename)); + const filename = this.props.filenames[this.state.imgId]; + const fileUrl = Utils.getFileUrl(filename); var content; - var bgClass = ''; if (this.state.loaded[this.state.imgId]) { - var fileInfo = Utils.splitFileLocation(filename); - var fileType = Utils.getFileType(fileInfo.ext); + // if a file has been loaded, we also have its info + const fileInfo = this.state.fileInfo.get(filename); + + const extension = Utils.splitFileLocation(filename).ext; + const fileType = Utils.getFileType(extension); if (fileType === 'image') { - if (!(filename in this.state.fileMimes)) { - Client.getFileInfo( - filename, - (data) => { - if (this.canSetState) { - var fileMimes = this.state.fileMimes; - fileMimes[filename] = data.mime; - this.setState(fileMimes); - } - }, - () => {} - ); + let previewUrl; + if (fileInfo.has_preview_image) { + previewUrl = this.getPreviewImagePath(filename); + } else { + previewUrl = fileUrl; } - var playbackControls = ''; - if (this.state.fileMimes[filename] === 'image/gif' && !(filename in this.state.isLoading)) { - if (filename in this.state.isPlaying) { - playbackControls = ( -
- );
- playbackControls = '';
- }
-
- // image files just show a preview of the file
content = (
-
- {loadingIndicator}
- {playbackControls}
-
-
- {'Previewing ' + percentage + '%'}
-
-
-
+ {progressView}
+