From e50b642e430b8d5059b094c81ff3f2d46546698f Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 30 Apr 2019 16:45:26 -0400 Subject: [PATCH] MM-14030 Add format and frame count to image metadata (#10757) * MM-14030 Add initial CountFrames * MM-14030 Add format and frame count to image metadata * Fix tests and stop using image dimensions from OpenGraph * Fix copyright header * Move license to NOTICE.txt --- NOTICE.txt | 39 +++ app/post_metadata.go | 31 ++- app/post_metadata_test.go | 122 +++++---- model/post_metadata.go | 6 + scripts/license-check.sh | 2 +- utils/imgutils/gif.go | 509 +++++++++++++++++++++++++++++++++++++ utils/imgutils/gif_test.go | 80 ++++++ 7 files changed, 712 insertions(+), 77 deletions(-) create mode 100644 utils/imgutils/gif.go create mode 100644 utils/imgutils/gif_test.go diff --git a/NOTICE.txt b/NOTICE.txt index 8196ac6ed8..f68e1957ae 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -8,6 +8,45 @@ This document includes a list of open source components used in Mattermost Serve ----- +## Go + +This product uses the Go programming language by the Go authors. + +* HOMEPAGE: + * https://golang.org + +* LICENSE: BSD-style + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + ## Masterminds/squirrel This product contains 'squirrel' by GitHub user "Masterminds". diff --git a/app/post_metadata.go b/app/post_metadata.go index cb2eac7a05..0a86601b3e 100644 --- a/app/post_metadata.go +++ b/app/post_metadata.go @@ -4,6 +4,7 @@ package app import ( + "bytes" "image" "io" "net/http" @@ -15,6 +16,7 @@ import ( "github.com/mattermost/mattermost-server/mlog" "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/utils" + "github.com/mattermost/mattermost-server/utils/imgutils" "github.com/mattermost/mattermost-server/utils/markdown" ) @@ -180,16 +182,7 @@ func (a *App) getImagesForPost(post *model.Post, imageURLs []string, isNewPost b continue } - if image.Width != 0 || image.Height != 0 { - // The site has already told us the image dimensions - images[imageURL] = &model.PostImage{ - Width: int(image.Width), - Height: int(image.Height), - } - } else { - // The site did not specify its image dimensions - imageURLs = append(imageURLs, imageURL) - } + imageURLs = append(imageURLs, imageURL) } } } @@ -498,7 +491,12 @@ func (a *App) parseLinkMetadata(requestURL string, body io.Reader, contentType s } func parseImages(body io.Reader) (*model.PostImage, error) { - config, _, err := image.DecodeConfig(body) + // Store any data that is read for the config for any further processing + buf := &bytes.Buffer{} + t := io.TeeReader(body, buf) + + // Read the image config to get the format and dimensions + config, format, err := image.DecodeConfig(t) if err != nil { return nil, err } @@ -506,6 +504,17 @@ func parseImages(body io.Reader) (*model.PostImage, error) { image := &model.PostImage{ Width: config.Width, Height: config.Height, + Format: format, + } + + if format == "gif" { + // Decoding the config may have read some of the image data, so re-read the data that has already been read first + frameCount, err := imgutils.CountFrames(io.MultiReader(buf, body)) + if err != nil { + return nil, err + } + + image.FrameCount = frameCount } return image, nil diff --git a/app/post_metadata_test.go b/app/post_metadata_test.go index 3af1539a7e..9f799056b3 100644 --- a/app/post_metadata_test.go +++ b/app/post_metadata_test.go @@ -257,10 +257,12 @@ func TestPreparePostForClient(t *testing.T) { imageDimensions := clientPost.Metadata.Images require.Len(t, imageDimensions, 2) assert.Equal(t, &model.PostImage{ + Format: "png", Width: 1068, Height: 552, }, imageDimensions["https://github.com/hmhealey/test-files/raw/master/logoVertical.png"]) assert.Equal(t, &model.PostImage{ + Format: "png", Width: 501, Height: 501, }, imageDimensions["https://github.com/hmhealey/test-files/raw/master/icon.png"]) @@ -310,6 +312,7 @@ func TestPreparePostForClient(t *testing.T) { imageDimensions := clientPost.Metadata.Images require.Len(t, imageDimensions, 1) assert.Equal(t, &model.PostImage{ + Format: "png", Width: 1068, Height: 552, }, imageDimensions["https://github.com/hmhealey/test-files/raw/master/logoVertical.png"]) @@ -354,6 +357,7 @@ func TestPreparePostForClient(t *testing.T) { imageDimensions := clientPost.Metadata.Images require.Len(t, imageDimensions, 1) assert.Equal(t, &model.PostImage{ + Format: "png", Width: 420, Height: 420, }, imageDimensions["https://avatars1.githubusercontent.com/u/3277310?s=400&v=4"]) @@ -391,6 +395,7 @@ func TestPreparePostForClient(t *testing.T) { imageDimensions := clientPost.Metadata.Images require.Len(t, imageDimensions, 1) assert.Equal(t, &model.PostImage{ + Format: "png", Width: 501, Height: 501, }, imageDimensions["https://github.com/hmhealey/test-files/raw/master/icon.png"]) @@ -643,6 +648,7 @@ func TestGetImagesForPost(t *testing.T) { assert.Equal(t, images, map[string]*model.PostImage{ imageURL: { + Format: "png", Width: 408, Height: 336, }, @@ -671,48 +677,7 @@ func TestGetImagesForPost(t *testing.T) { assert.Equal(t, images, map[string]*model.PostImage{}) }) - t.Run("for an OpenGraph image with dimensions", func(t *testing.T) { - th := Setup(t) - defer th.TearDown() - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" - }) - - ogURL := "https://example.com/index.html" - imageURL := "https://example.com/image.png" - - post := &model.Post{ - Metadata: &model.PostMetadata{ - Embeds: []*model.PostEmbed{ - { - Type: model.POST_EMBED_OPENGRAPH, - URL: ogURL, - Data: &opengraph.OpenGraph{ - Images: []*opengraph.Image{ - { - URL: imageURL, - Width: 100, - Height: 200, - }, - }, - }, - }, - }, - }, - } - - images := th.App.getImagesForPost(post, []string{}, false) - - assert.Equal(t, images, map[string]*model.PostImage{ - imageURL: { - Width: 100, - Height: 200, - }, - }) - }) - - t.Run("for an OpenGraph image without dimensions", func(t *testing.T) { + t.Run("for an OpenGraph image", func(t *testing.T) { th := Setup(t) defer th.TearDown() @@ -759,13 +724,14 @@ func TestGetImagesForPost(t *testing.T) { assert.Equal(t, images, map[string]*model.PostImage{ imageURL: { + Format: "png", Width: 200, Height: 300, }, }) }) - t.Run("with an OpenGraph image with a secure_url and dimensions", func(t *testing.T) { + t.Run("with an OpenGraph image with a secure_url", func(t *testing.T) { th := Setup(t) defer th.TearDown() @@ -773,8 +739,22 @@ func TestGetImagesForPost(t *testing.T) { *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "127.0.0.1" }) - ogURL := "https://example.com/index.html" - imageURL := "https://example.com/secure_image.png" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/secure_image.png" { + w.Header().Set("Content-Type", "image/png") + + img := image.NewGray(image.Rect(0, 0, 300, 400)) + + var encoder png.Encoder + encoder.Encode(w, img) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + ogURL := server.URL + "/index.html" + imageURL := server.URL + "/secure_image.png" post := &model.Post{ Metadata: &model.PostMetadata{ @@ -785,9 +765,7 @@ func TestGetImagesForPost(t *testing.T) { Data: &opengraph.OpenGraph{ Images: []*opengraph.Image{ { - URL: imageURL, - Width: 300, - Height: 400, + SecureURL: imageURL, }, }, }, @@ -800,6 +778,7 @@ func TestGetImagesForPost(t *testing.T) { assert.Equal(t, images, map[string]*model.PostImage{ imageURL: { + Format: "png", Width: 300, Height: 400, }, @@ -853,6 +832,7 @@ func TestGetImagesForPost(t *testing.T) { assert.Equal(t, images, map[string]*model.PostImage{ imageURL: { + Format: "png", Width: 400, Height: 500, }, @@ -1948,6 +1928,7 @@ func TestParseLinkMetadata(t *testing.T) { assert.Nil(t, og) assert.Equal(t, &model.PostImage{ + Format: "png", Width: 408, Height: 336, }, dimensions) @@ -1991,20 +1972,34 @@ func TestParseLinkMetadata(t *testing.T) { func TestParseImages(t *testing.T) { for name, testCase := range map[string]struct { - FileName string - ExpectedWidth int - ExpectedHeight int - ExpectError bool + FileName string + Expected *model.PostImage + ExpectError bool }{ "png": { - FileName: "test.png", - ExpectedWidth: 408, - ExpectedHeight: 336, + FileName: "test.png", + Expected: &model.PostImage{ + Width: 408, + Height: 336, + Format: "png", + }, }, "animated gif": { - FileName: "testgif.gif", - ExpectedWidth: 118, - ExpectedHeight: 118, + FileName: "testgif.gif", + Expected: &model.PostImage{ + Width: 118, + Height: 118, + Format: "gif", + FrameCount: 4, + }, + }, + "tiff": { + FileName: "test.tiff", + Expected: &model.PostImage{ + Width: 701, + Height: 701, + Format: "tiff", + }, }, "not an image": { FileName: "README.md", @@ -2015,15 +2010,12 @@ func TestParseImages(t *testing.T) { file, err := testutils.ReadTestFile(testCase.FileName) require.Nil(t, err) - dimensions, err := parseImages(bytes.NewReader(file)) + result, err := parseImages(bytes.NewReader(file)) if testCase.ExpectError { - require.NotNil(t, err) + assert.NotNil(t, err) } else { - require.Nil(t, err) - - require.NotNil(t, dimensions) - require.Equal(t, testCase.ExpectedWidth, dimensions.Width) - require.Equal(t, testCase.ExpectedHeight, dimensions.Height) + assert.Nil(t, err) + assert.Equal(t, testCase.Expected, result) } }) } diff --git a/model/post_metadata.go b/model/post_metadata.go index 40712d44e7..5fb394b92d 100644 --- a/model/post_metadata.go +++ b/model/post_metadata.go @@ -31,6 +31,12 @@ type PostMetadata struct { type PostImage struct { Width int `json:"width"` Height int `json:"height"` + + // Format is the name of the image format as used by image/go such as "png", "gif", or "jpeg". + Format string `json:"format"` + + // FrameCount stores the number of frames in this image, if it is an animated gif. It will be 0 for other formats. + FrameCount int `json:"frame_count"` } func (o *PostImage) ToJson() string { diff --git a/scripts/license-check.sh b/scripts/license-check.sh index c9c60e081e..22f9d406c8 100755 --- a/scripts/license-check.sh +++ b/scripts/license-check.sh @@ -5,7 +5,7 @@ count=0 for fileType in GoFiles; do for file in `go list -f $'{{range .GoFiles}}{{$.Dir}}/{{.}}\n{{end}}' "$@"`; do case $file in - */utils/lru.go|*/store/storetest/mocks/*|*/services/*/mocks/*|*/app/plugin/jira/plugin_*|*/plugin/plugintest/*|*/app/plugin/zoom/plugin_*) + */utils/lru.go|*/utils/imgutils/gif.go|*/store/storetest/mocks/*|*/services/*/mocks/*|*/app/plugin/jira/plugin_*|*/plugin/plugintest/*|*/app/plugin/zoom/plugin_*) # Third-party, doesn't require a header. ;; *) diff --git a/utils/imgutils/gif.go b/utils/imgutils/gif.go new file mode 100644 index 0000000000..0e48f25ece --- /dev/null +++ b/utils/imgutils/gif.go @@ -0,0 +1,509 @@ +// Copyright (c) 2011 The Go Authors. +// Modified work: Copyright (c) 2019 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package imgutils + +// This contains a portion of Go's image/go library, modified to count the number of frames in a gif without loading +// the entire image into memory. + +import ( + "bufio" + "compress/lzw" + "errors" + "fmt" + "image" + "image/color" + "io" +) + +var ( + errNotEnough = errors.New("gif: not enough image data") + errTooMuch = errors.New("gif: too much image data") +) + +// If the io.Reader does not also have ReadByte, then decode will introduce its own buffering. +type reader interface { + io.Reader + io.ByteReader +} + +// Masks etc. +const ( + // Fields. + fColorTable = 1 << 7 + fInterlace = 1 << 6 + fColorTableBitsMask = 7 + + // Graphic control flags. + gcTransparentColorSet = 1 << 0 + gcDisposalMethodMask = 7 << 2 +) + +// Disposal Methods. +const ( + DisposalNone = 0x01 + DisposalBackground = 0x02 + DisposalPrevious = 0x03 +) + +// Section indicators. +const ( + sExtension = 0x21 + sImageDescriptor = 0x2C + sTrailer = 0x3B +) + +// Extensions. +const ( + eText = 0x01 // Plain Text + eGraphicControl = 0xF9 // Graphic Control + eComment = 0xFE // Comment + eApplication = 0xFF // Application +) + +func readFull(r io.Reader, b []byte) error { + _, err := io.ReadFull(r, b) + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return err +} + +func readByte(r io.ByteReader) (byte, error) { + b, err := r.ReadByte() + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return b, err +} + +// decoder is the type used to decode a GIF file. +type decoder struct { + r reader + + // From header. + vers string + width int + height int + loopCount int + delayTime int + backgroundIndex byte + disposalMethod byte + + // From image descriptor. + imageFields byte + + // From graphics control. + transparentIndex byte + hasTransparentIndex bool + + // Computed. + globalColorTable color.Palette + + // Used when decoding. + imageCount int + tmp [1024]byte // must be at least 768 so we can read color table +} + +// blockReader parses the block structure of GIF image data, which comprises +// (n, (n bytes)) blocks, with 1 <= n <= 255. It is the reader given to the +// LZW decoder, which is thus immune to the blocking. After the LZW decoder +// completes, there will be a 0-byte block remaining (0, ()), which is +// consumed when checking that the blockReader is exhausted. +// +// To avoid the allocation of a bufio.Reader for the lzw Reader, blockReader +// implements io.ReadByte and buffers blocks into the decoder's "tmp" buffer. +type blockReader struct { + d *decoder + i, j uint8 // d.tmp[i:j] contains the buffered bytes + err error +} + +func (b *blockReader) fill() { + if b.err != nil { + return + } + b.j, b.err = readByte(b.d.r) + if b.j == 0 && b.err == nil { + b.err = io.EOF + } + if b.err != nil { + return + } + + b.i = 0 + b.err = readFull(b.d.r, b.d.tmp[:b.j]) + if b.err != nil { + b.j = 0 + } +} + +func (b *blockReader) ReadByte() (byte, error) { + if b.i == b.j { + b.fill() + if b.err != nil { + return 0, b.err + } + } + + c := b.d.tmp[b.i] + b.i++ + return c, nil +} + +// blockReader must implement io.Reader, but its Read shouldn't ever actually +// be called in practice. The compress/lzw package will only call ReadByte. +func (b *blockReader) Read(p []byte) (int, error) { + if len(p) == 0 || b.err != nil { + return 0, b.err + } + if b.i == b.j { + b.fill() + if b.err != nil { + return 0, b.err + } + } + + n := copy(p, b.d.tmp[b.i:b.j]) + b.i += uint8(n) + return n, nil +} + +// close primarily detects whether or not a block terminator was encountered +// after reading a sequence of data sub-blocks. It allows at most one trailing +// sub-block worth of data. I.e., if some number of bytes exist in one sub-block +// following the end of LZW data, the very next sub-block must be the block +// terminator. If the very end of LZW data happened to fill one sub-block, at +// most one more sub-block of length 1 may exist before the block-terminator. +// These accommodations allow us to support GIFs created by less strict encoders. +// See https://golang.org/issue/16146. +func (b *blockReader) close() error { + if b.err == io.EOF { + // A clean block-sequence terminator was encountered while reading. + return nil + } else if b.err != nil { + // Some other error was encountered while reading. + return b.err + } + + if b.i == b.j { + // We reached the end of a sub block reading LZW data. We'll allow at + // most one more sub block of data with a length of 1 byte. + b.fill() + if b.err == io.EOF { + return nil + } else if b.err != nil { + return b.err + } else if b.j > 1 { + return errTooMuch + } + } + + // Part of a sub-block remains buffered. We expect that the next attempt to + // buffer a sub-block will reach the block terminator. + b.fill() + if b.err == io.EOF { + return nil + } else if b.err != nil { + return b.err + } + + return errTooMuch +} + +// decode reads a GIF image from r and stores the result in d. +func (d *decoder) decode(r io.Reader, configOnly, keepAllFrames bool) error { + // Add buffering if r does not provide ReadByte. + if rr, ok := r.(reader); ok { + d.r = rr + } else { + d.r = bufio.NewReader(r) + } + + d.loopCount = -1 + + err := d.readHeaderAndScreenDescriptor() + if err != nil { + return err + } + if configOnly { + return nil + } + + for { + c, err := readByte(d.r) + if err != nil { + return fmt.Errorf("gif: reading frames: %v", err) + } + switch c { + case sExtension: + if err = d.readExtension(); err != nil { + return err + } + + case sImageDescriptor: + if err = d.readImageDescriptor(keepAllFrames); err != nil { + return err + } + + case sTrailer: + if d.imageCount == 0 { + return fmt.Errorf("gif: missing image data") + } + return nil + + default: + return fmt.Errorf("gif: unknown block type: 0x%.2x", c) + } + } +} + +func (d *decoder) readHeaderAndScreenDescriptor() error { + err := readFull(d.r, d.tmp[:13]) + if err != nil { + return fmt.Errorf("gif: reading header: %v", err) + } + d.vers = string(d.tmp[:6]) + if d.vers != "GIF87a" && d.vers != "GIF89a" { + return fmt.Errorf("gif: can't recognize format %q", d.vers) + } + d.width = int(d.tmp[6]) + int(d.tmp[7])<<8 + d.height = int(d.tmp[8]) + int(d.tmp[9])<<8 + if fields := d.tmp[10]; fields&fColorTable != 0 { + d.backgroundIndex = d.tmp[11] + // readColorTable overwrites the contents of d.tmp, but that's OK. + if d.globalColorTable, err = d.readColorTable(fields); err != nil { + return err + } + } + // d.tmp[12] is the Pixel Aspect Ratio, which is ignored. + return nil +} + +func (d *decoder) readColorTable(fields byte) (color.Palette, error) { + n := 1 << (1 + uint(fields&fColorTableBitsMask)) + err := readFull(d.r, d.tmp[:3*n]) + if err != nil { + return nil, fmt.Errorf("gif: reading color table: %s", err) + } + j, p := 0, make(color.Palette, n) + for i := range p { + p[i] = color.RGBA{d.tmp[j+0], d.tmp[j+1], d.tmp[j+2], 0xFF} + j += 3 + } + return p, nil +} + +func (d *decoder) readExtension() error { + extension, err := readByte(d.r) + if err != nil { + return fmt.Errorf("gif: reading extension: %v", err) + } + size := 0 + switch extension { + case eText: + size = 13 + case eGraphicControl: + return d.readGraphicControl() + case eComment: + // nothing to do but read the data. + case eApplication: + b, err := readByte(d.r) + if err != nil { + return fmt.Errorf("gif: reading extension: %v", err) + } + // The spec requires size be 11, but Adobe sometimes uses 10. + size = int(b) + default: + return fmt.Errorf("gif: unknown extension 0x%.2x", extension) + } + if size > 0 { + if err := readFull(d.r, d.tmp[:size]); err != nil { + return fmt.Errorf("gif: reading extension: %v", err) + } + } + + // Application Extension with "NETSCAPE2.0" as string and 1 in data means + // this extension defines a loop count. + if extension == eApplication && string(d.tmp[:size]) == "NETSCAPE2.0" { + n, err := d.readBlock() + if err != nil { + return fmt.Errorf("gif: reading extension: %v", err) + } + if n == 0 { + return nil + } + if n == 3 && d.tmp[0] == 1 { + d.loopCount = int(d.tmp[1]) | int(d.tmp[2])<<8 + } + } + for { + n, err := d.readBlock() + if err != nil { + return fmt.Errorf("gif: reading extension: %v", err) + } + if n == 0 { + return nil + } + } +} + +func (d *decoder) readGraphicControl() error { + if err := readFull(d.r, d.tmp[:6]); err != nil { + return fmt.Errorf("gif: can't read graphic control: %s", err) + } + if d.tmp[0] != 4 { + return fmt.Errorf("gif: invalid graphic control extension block size: %d", d.tmp[0]) + } + flags := d.tmp[1] + d.disposalMethod = (flags & gcDisposalMethodMask) >> 2 + d.delayTime = int(d.tmp[2]) | int(d.tmp[3])<<8 + if flags&gcTransparentColorSet != 0 { + d.transparentIndex = d.tmp[4] + d.hasTransparentIndex = true + } + if d.tmp[5] != 0 { + return fmt.Errorf("gif: invalid graphic control extension block terminator: %d", d.tmp[5]) + } + return nil +} + +func (d *decoder) readImageDescriptor(keepAllFrames bool) error { + m, err := d.newImageFromDescriptor() + if err != nil { + return err + } + useLocalColorTable := d.imageFields&fColorTable != 0 + if useLocalColorTable { + m.Palette, err = d.readColorTable(d.imageFields) + if err != nil { + return err + } + } else { + if d.globalColorTable == nil { + return errors.New("gif: no color table") + } + m.Palette = d.globalColorTable + } + if d.hasTransparentIndex { + if !useLocalColorTable { + // Clone the global color table. + m.Palette = append(color.Palette(nil), d.globalColorTable...) + } + if ti := int(d.transparentIndex); ti < len(m.Palette) { + m.Palette[ti] = color.RGBA{} + } else { + // The transparentIndex is out of range, which is an error + // according to the spec, but Firefox and Google Chrome + // seem OK with this, so we enlarge the palette with + // transparent colors. See golang.org/issue/15059. + p := make(color.Palette, ti+1) + copy(p, m.Palette) + for i := len(m.Palette); i < len(p); i++ { + p[i] = color.RGBA{} + } + m.Palette = p + } + } + litWidth, err := readByte(d.r) + if err != nil { + return fmt.Errorf("gif: reading image data: %v", err) + } + if litWidth < 2 || litWidth > 8 { + return fmt.Errorf("gif: pixel size in decode out of range: %d", litWidth) + } + // A wonderfully Go-like piece of magic. + br := &blockReader{d: d} + lzwr := lzw.NewReader(br, lzw.LSB, int(litWidth)) + defer lzwr.Close() + if err = readFull(lzwr, m.Pix); err != nil { + if err != io.ErrUnexpectedEOF { + return fmt.Errorf("gif: reading image data: %v", err) + } + return errNotEnough + } + // In theory, both lzwr and br should be exhausted. Reading from them + // should yield (0, io.EOF). + // + // The spec (Appendix F - Compression), says that "An End of + // Information code... must be the last code output by the encoder + // for an image". In practice, though, giflib (a widely used C + // library) does not enforce this, so we also accept lzwr returning + // io.ErrUnexpectedEOF (meaning that the encoded stream hit io.EOF + // before the LZW decoder saw an explicit end code), provided that + // the io.ReadFull call above successfully read len(m.Pix) bytes. + // See https://golang.org/issue/9856 for an example GIF. + if n, err := lzwr.Read(d.tmp[256:257]); n != 0 || (err != io.EOF && err != io.ErrUnexpectedEOF) { + if err != nil { + return fmt.Errorf("gif: reading image data: %v", err) + } + return errTooMuch + } + + // In practice, some GIFs have an extra byte in the data sub-block + // stream, which we ignore. See https://golang.org/issue/16146. + if err := br.close(); err == errTooMuch { + return errTooMuch + } else if err != nil { + return fmt.Errorf("gif: reading image data: %v", err) + } + + d.imageCount += 1 + + return nil +} + +func (d *decoder) newImageFromDescriptor() (*image.Paletted, error) { + if err := readFull(d.r, d.tmp[:9]); err != nil { + return nil, fmt.Errorf("gif: can't read image descriptor: %s", err) + } + left := int(d.tmp[0]) + int(d.tmp[1])<<8 + top := int(d.tmp[2]) + int(d.tmp[3])<<8 + width := int(d.tmp[4]) + int(d.tmp[5])<<8 + height := int(d.tmp[6]) + int(d.tmp[7])<<8 + d.imageFields = d.tmp[8] + + // The GIF89a spec, Section 20 (Image Descriptor) says: "Each image must + // fit within the boundaries of the Logical Screen, as defined in the + // Logical Screen Descriptor." + // + // This is conceptually similar to testing + // frameBounds := image.Rect(left, top, left+width, top+height) + // imageBounds := image.Rect(0, 0, d.width, d.height) + // if !frameBounds.In(imageBounds) { etc } + // but the semantics of the Go image.Rectangle type is that r.In(s) is true + // whenever r is an empty rectangle, even if r.Min.X > s.Max.X. Here, we + // want something stricter. + // + // Note that, by construction, left >= 0 && top >= 0, so we only have to + // explicitly compare frameBounds.Max (left+width, top+height) against + // imageBounds.Max (d.width, d.height) and not frameBounds.Min (left, top) + // against imageBounds.Min (0, 0). + if left+width > d.width || top+height > d.height { + return nil, errors.New("gif: frame bounds larger than image bounds") + } + return image.NewPaletted(image.Rectangle{ + Min: image.Point{left, top}, + Max: image.Point{left + width, top + height}, + }, nil), nil +} + +func (d *decoder) readBlock() (int, error) { + n, err := readByte(d.r) + if n == 0 || err != nil { + return 0, err + } + if err := readFull(d.r, d.tmp[:n]); err != nil { + return 0, err + } + return int(n), nil +} + +func CountFrames(r io.Reader) (int, error) { + var d decoder + if err := d.decode(r, false, true); err != nil { + return -1, err + } + return d.imageCount, nil +} diff --git a/utils/imgutils/gif_test.go b/utils/imgutils/gif_test.go new file mode 100644 index 0000000000..240ec11071 --- /dev/null +++ b/utils/imgutils/gif_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package imgutils + +import ( + "bytes" + "testing" + + "github.com/mattermost/mattermost-server/utils/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCountFrames(t *testing.T) { + header := []byte{ + 'G', 'I', 'F', '8', '9', 'a', // header + 1, 0, 1, 0, // width and height of 1 by 1 + 128, 0, 0, // other header information + 0, 0, 0, 1, 1, 1, // color table + } + frame := []byte{ + 0x2c, // block introducer + 0, 0, 0, 0, 1, 0, 1, 0, // position and dimensions of the frame + 0, // other frame information + 0x2, 0x2, 0x4c, 0x1, 0, // encoded pixel data + } + trailer := []byte{0x3b} + + t.Run("should count the frames of a static gif", func(t *testing.T) { + var b []byte + b = append(b, header...) + b = append(b, frame...) + b = append(b, trailer...) + + count, err := CountFrames(bytes.NewReader(b)) + + assert.Nil(t, err) + assert.Equal(t, 1, count) + }) + + t.Run("should count the frames of an animated gif", func(t *testing.T) { + var b []byte + b = append(b, header...) + for i := 0; i < 100; i++ { + b = append(b, frame...) + } + b = append(b, trailer...) + + count, err := CountFrames(bytes.NewReader(b)) + + assert.Nil(t, err) + assert.Equal(t, 100, count) + }) + + t.Run("should count the frames of an actual animated gif", func(t *testing.T) { + b, err := testutils.ReadTestFile("testgif.gif") + require.Nil(t, err) + + count, err := CountFrames(bytes.NewReader(b)) + + assert.Nil(t, err) + assert.Equal(t, 4, count) + }) + + t.Run("should return an error for a non-gif image", func(t *testing.T) { + b, err := testutils.ReadTestFile("test.png") + require.Nil(t, err) + + _, err = CountFrames(bytes.NewReader(b)) + + assert.NotNil(t, err) + }) + + t.Run("should return an error for garbage data", func(t *testing.T) { + _, err := CountFrames(bytes.NewReader([]byte("garbage data"))) + + assert.NotNil(t, err) + }) +}