diff --git a/app/bot.go b/app/bot.go index 68c260f7de..ecba1f1ca5 100644 --- a/app/bot.go +++ b/app/bot.go @@ -11,6 +11,7 @@ import ( "mime/multipart" "net/http" + "github.com/mattermost/mattermost-server/v5/app/imaging" "github.com/mattermost/mattermost-server/v5/app/request" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/shared/i18n" @@ -526,7 +527,7 @@ func (a *App) SetBotIconImage(botUserId string, file io.ReadSeeker) *model.AppEr return err } - if _, err := parseSVG(file); err != nil { + if _, err := imaging.ParseSVG(file); err != nil { return model.NewAppError("SetBotIconImage", "api.bot.set_bot_icon_image.parse.app_error", nil, err.Error(), http.StatusBadRequest) } diff --git a/app/brand.go b/app/brand.go index 88bbe94c1e..2895607758 100644 --- a/app/brand.go +++ b/app/brand.go @@ -5,10 +5,6 @@ package app import ( "bytes" - "image" - _ "image/gif" - _ "image/jpeg" - "image/png" "mime/multipart" "net/http" "time" @@ -32,28 +28,17 @@ func (a *App) SaveBrandImage(imageData *multipart.FileHeader) *model.AppError { } defer file.Close() - // Decode image config first to check dimensions before loading the whole thing into memory later on - config, _, err := image.DecodeConfig(file) - if err != nil { - return model.NewAppError("SaveBrandImage", "brand.save_brand_image.decode_config.app_error", nil, err.Error(), http.StatusBadRequest) + if err = checkImageLimits(file); err != nil { + return model.NewAppError("SaveBrandImage", "brand.save_brand_image.check_image_limits.app_error", nil, err.Error(), http.StatusBadRequest) } - // This casting is done to prevent overflow on 32 bit systems (not needed - // in 64 bits systems because images can't have more than 32 bits height or - // width) - if int64(config.Width)*int64(config.Height) > model.MaxImageSize { - return model.NewAppError("SaveBrandImage", "brand.save_brand_image.too_large.app_error", nil, "", http.StatusBadRequest) - } - - file.Seek(0, 0) - - img, _, err := image.Decode(file) + img, _, err := a.srv.imgDecoder.Decode(file) if err != nil { return model.NewAppError("SaveBrandImage", "brand.save_brand_image.decode.app_error", nil, err.Error(), http.StatusBadRequest) } buf := new(bytes.Buffer) - err = png.Encode(buf, img) + err = a.srv.imgEncoder.EncodePNG(buf, img) if err != nil { return model.NewAppError("SaveBrandImage", "brand.save_brand_image.encode.app_error", nil, err.Error(), http.StatusInternalServerError) } diff --git a/app/file.go b/app/file.go index f4d970122f..07899935f8 100644 --- a/app/file.go +++ b/app/file.go @@ -11,10 +11,7 @@ import ( "encoding/base64" "fmt" "image" - "image/color" - "image/draw" "image/gif" - "image/jpeg" "io" "mime/multipart" "net/http" @@ -27,13 +24,7 @@ import ( "sync" "time" - "github.com/disintegration/imaging" - _ "github.com/oov/psd" - "github.com/pkg/errors" - "github.com/rwcarlsen/goexif/exif" - _ "golang.org/x/image/bmp" - _ "golang.org/x/image/tiff" - + "github.com/mattermost/mattermost-server/v5/app/imaging" "github.com/mattermost/mattermost-server/v5/app/request" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/plugin" @@ -42,41 +33,20 @@ import ( "github.com/mattermost/mattermost-server/v5/shared/mlog" "github.com/mattermost/mattermost-server/v5/store" "github.com/mattermost/mattermost-server/v5/utils" + + "github.com/pkg/errors" ) const ( - /* - EXIF Image Orientations - 1 2 3 4 5 6 7 8 - - 888888 888888 88 88 8888888888 88 88 8888888888 - 88 88 88 88 88 88 88 88 88 88 88 88 - 8888 8888 8888 8888 88 8888888888 8888888888 88 - 88 88 88 88 - 88 88 888888 888888 - */ - Upright = 1 - UprightMirrored = 2 - UpsideDown = 3 - UpsideDownMirrored = 4 - RotatedCWMirrored = 5 - RotatedCCW = 6 - RotatedCCWMirrored = 7 - RotatedCW = 8 - - MaxImageSize = int64(6048 * 4032) // 24 megapixels, roughly 36MB as a raw image - ImageThumbnailWidth = 120 - ImageThumbnailHeight = 100 - ImageThumbnailRatio = float64(ImageThumbnailHeight) / float64(ImageThumbnailWidth) - ImagePreviewWidth = 1920 - - maxUploadInitialBufferSize = 1024 * 1024 // 1Mb - - // Deprecated - ImageThumbnailPixelWidth = 120 - ImageThumbnailPixelHeight = 100 - ImagePreviewPixelWidth = 1920 - MaxContentExtractionSize = 1024 * 1024 // 1Mb + maxImageRes = int64(6048 * 4032) // 24 megapixels, up to ~196MB as a raw image + imageThumbnailWidth = 120 + imageThumbnailHeight = 100 + imagePreviewWidth = 1920 + miniPreviewImageWidth = 16 + miniPreviewImageHeight = 16 + jpegEncQuality = 90 + maxUploadInitialBufferSize = 1024 * 1024 // 1MB + maxContentExtractionSize = 1024 * 1024 // 1MB ) func (a *App) FileBackend() (filestore.FileBackend, *model.AppError) { @@ -683,6 +653,9 @@ type UploadFileTask struct { pluginsEnvironment *plugin.Environment writeFile func(io.Reader, string) (int64, *model.AppError) saveToDatabase func(*model.FileInfo) (*model.FileInfo, error) + + imgDecoder *imaging.Decoder + imgEncoder *imaging.Encoder } func (t *UploadFileTask) init(a *App) { @@ -729,6 +702,8 @@ func (a *App) UploadFileX(c *request.Context, channelID, name string, input io.R Name: filepath.Base(name), Input: input, maxFileSize: *a.Config().FileSettings.MaxFileSize, + imgDecoder: a.srv.imgDecoder, + imgEncoder: a.srv.imgEncoder, } for _, o := range opts { o(t) @@ -811,7 +786,7 @@ func (a *App) UploadFileX(c *request.Context, channelID, name string, input io.R func (t *UploadFileTask) preprocessImage() *model.AppError { // If SVG, attempt to extract dimensions and then return if t.fileinfo.MimeType == "image/svg+xml" { - svgInfo, err := parseSVG(t.teeInput) + svgInfo, err := imaging.ParseSVG(t.teeInput) if err != nil { mlog.Warn("Failed to parse SVG", mlog.Err(err)) } @@ -824,21 +799,17 @@ func (t *UploadFileTask) preprocessImage() *model.AppError { } // If we fail to decode, return "as is". - config, _, err := image.DecodeConfig(t.teeInput) + w, h, err := imaging.GetDimensions(t.teeInput) if err != nil { return nil } + t.fileinfo.Width = w + t.fileinfo.Height = h - t.fileinfo.Width = config.Width - t.fileinfo.Height = config.Height - - // Check dimensions before loading the whole thing into memory later on. - // This casting is done to prevent overflow on 32 bit systems (not needed - // in 64 bits systems because images can't have more than 32 bits height or - // width) - if int64(t.fileinfo.Width)*int64(t.fileinfo.Height) > MaxImageSize { + if err = checkImageResolutionLimit(w, h); err != nil { return t.newAppError("api.file.upload_file.large_image_detailed.app_error", http.StatusBadRequest) } + t.fileinfo.HasPreviewImage = true nameWithoutExtension := t.Name[:strings.LastIndex(t.Name, ".")] t.fileinfo.PreviewPath = t.pathPrefix() + nameWithoutExtension + "_preview.jpg" @@ -847,11 +818,11 @@ func (t *UploadFileTask) preprocessImage() *model.AppError { // check the image orientation with goexif; consume the bytes we // already have first, then keep Tee-ing from input. // TODO: try to reuse exif's .Raw buffer rather than Tee-ing - if t.imageOrientation, err = getImageOrientation(io.MultiReader(bytes.NewReader(t.buf.Bytes()), t.teeInput)); err == nil && - (t.imageOrientation == RotatedCWMirrored || - t.imageOrientation == RotatedCCW || - t.imageOrientation == RotatedCCWMirrored || - t.imageOrientation == RotatedCW) { + if t.imageOrientation, err = imaging.GetImageOrientation(io.MultiReader(bytes.NewReader(t.buf.Bytes()), t.teeInput)); err == nil && + (t.imageOrientation == imaging.RotatedCWMirrored || + t.imageOrientation == imaging.RotatedCCW || + t.imageOrientation == imaging.RotatedCCWMirrored || + t.imageOrientation == imaging.RotatedCW) { t.fileinfo.Width, t.fileinfo.Height = t.fileinfo.Height, t.fileinfo.Width } @@ -877,35 +848,32 @@ func (t *UploadFileTask) postprocessImage(file io.Reader) { return } - decoded, typ := t.decoded, t.imageType + decoded, imgType := t.decoded, t.imageType if decoded == nil { var err error - decoded, typ, err = image.Decode(file) + var release func() + decoded, imgType, release, err = t.imgDecoder.DecodeMemBounded(file) if err != nil { mlog.Error("Unable to decode image", mlog.Err(err)) return } + defer release() } - // Fill in the background of a potentially-transparent png file as - // white. - if typ == "png" { - dst := image.NewRGBA(decoded.Bounds()) - draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) - draw.Draw(dst, dst.Bounds(), decoded, decoded.Bounds().Min, draw.Over) - decoded = dst + // Fill in the background of a potentially-transparent png file as white + if imgType == "png" { + imaging.FillImageTransparency(decoded, image.White) } - decoded = makeImageUpright(decoded, t.imageOrientation) + decoded = imaging.MakeImageUpright(decoded, t.imageOrientation) if decoded == nil { return } - const jpegQuality = 90 writeJPEG := func(img image.Image, path string) { r, w := io.Pipe() go func() { - err := jpeg.Encode(w, img, &jpeg.Options{Quality: jpegQuality}) + err := t.imgEncoder.EncodeJPEG(w, img, jpegEncQuality) if err != nil { mlog.Error("Unable to encode image as jpeg", mlog.String("path", path), mlog.Err(err)) w.CloseWithError(err) @@ -926,18 +894,23 @@ func (t *UploadFileTask) postprocessImage(file io.Reader) { // This is needed on mobile in case of animated GIFs. go func() { defer wg.Done() - writeJPEG(genThumbnail(decoded), t.fileinfo.ThumbnailPath) + writeJPEG(imaging.GenerateThumbnail(decoded, imageThumbnailWidth, imageThumbnailHeight), t.fileinfo.ThumbnailPath) }() go func() { defer wg.Done() - writeJPEG(genPreview(decoded), t.fileinfo.PreviewPath) + writeJPEG(imaging.GeneratePreview(decoded, imagePreviewWidth), t.fileinfo.PreviewPath) }() go func() { defer wg.Done() if t.fileinfo.MiniPreview == nil { - t.fileinfo.MiniPreview = model.GenerateMiniPreviewImage(decoded) + if miniPreview, err := imaging.GenerateMiniPreviewImage(decoded, + miniPreviewImageWidth, miniPreviewImageHeight, jpegEncQuality); err != nil { + mlog.Info("Unable to generate mini preview image", mlog.Err(err)) + } else { + t.fileinfo.MiniPreview = &miniPreview + } } }() wg.Wait() @@ -984,11 +957,11 @@ func (a *App) DoUploadFileExpectModification(c *request.Context, now time.Time, return nil, data, err } - if orientation, err := getImageOrientation(bytes.NewReader(data)); err == nil && - (orientation == RotatedCWMirrored || - orientation == RotatedCCW || - orientation == RotatedCCWMirrored || - orientation == RotatedCW) { + if orientation, err := imaging.GetImageOrientation(bytes.NewReader(data)); err == nil && + (orientation == imaging.RotatedCWMirrored || + orientation == imaging.RotatedCCW || + orientation == imaging.RotatedCCWMirrored || + orientation == imaging.RotatedCW) { info.Width, info.Height = info.Height, info.Width } @@ -1000,12 +973,8 @@ func (a *App) DoUploadFileExpectModification(c *request.Context, now time.Time, info.Path = pathPrefix + filename if info.IsImage() { - // Check dimensions before loading the whole thing into memory later on - // This casting is done to prevent overflow on 32 bit systems (not needed - // in 64 bits systems because images can't have more than 32 bits height or - // width) - if int64(info.Width)*int64(info.Height) > MaxImageSize { - err := model.NewAppError("uploadFile", "api.file.upload_file.large_image.app_error", map[string]interface{}{"Filename": filename}, "", http.StatusBadRequest) + if limitErr := checkImageResolutionLimit(info.Width, info.Height); limitErr != nil { + err := model.NewAppError("uploadFile", "api.file.upload_file.large_image.app_error", map[string]interface{}{"Filename": filename}, limitErr.Error(), http.StatusBadRequest) return nil, data, err } @@ -1070,113 +1039,75 @@ func (a *App) HandleImages(previewPathList []string, thumbnailPathList []string, wg := new(sync.WaitGroup) for i := range fileData { - img, _, _ := prepareImage(fileData[i]) - if img != nil { - wg.Add(2) - go func(img image.Image, path string) { - defer wg.Done() - a.generateThumbnailImage(img, path) - }(img, thumbnailPathList[i]) - - go func(img image.Image, path string) { - defer wg.Done() - a.generatePreviewImage(img, path) - }(img, previewPathList[i]) + img, release, err := prepareImage(a.srv.imgDecoder, bytes.NewReader(fileData[i])) + if err != nil { + mlog.Debug("Failed to prepare image", mlog.Err(err)) + continue } + wg.Add(2) + go func(img image.Image, path string) { + defer wg.Done() + a.generateThumbnailImage(img, path) + }(img, thumbnailPathList[i]) + + go func(img image.Image, path string) { + defer wg.Done() + a.generatePreviewImage(img, path) + }(img, previewPathList[i]) + + wg.Wait() + release() } - wg.Wait() } -func prepareImage(fileData []byte) (image.Image, int, int) { +func prepareImage(imgDecoder *imaging.Decoder, imgData io.ReadSeeker) (img image.Image, release func(), err error) { // Decode image bytes into Image object - img, imgType, err := image.Decode(bytes.NewReader(fileData)) + var imgType string + img, imgType, release, err = imgDecoder.DecodeMemBounded(imgData) if err != nil { - mlog.Error("Unable to decode image", mlog.Err(err)) - return nil, 0, 0 + return nil, nil, fmt.Errorf("prepareImage: failed to decode image: %w", err) } - width := img.Bounds().Dx() - height := img.Bounds().Dy() - // 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) - draw.Draw(dst, dst.Bounds(), img, img.Bounds().Min, draw.Over) - img = dst + imaging.FillImageTransparency(img, image.White) } + imgData.Seek(0, io.SeekStart) + // Flip the image to be upright - orientation, _ := getImageOrientation(bytes.NewReader(fileData)) - img = makeImageUpright(img, orientation) - - return img, width, height -} - -func makeImageUpright(img image.Image, orientation int) image.Image { - switch orientation { - case UprightMirrored: - return imaging.FlipH(img) - case UpsideDown: - return imaging.Rotate180(img) - case UpsideDownMirrored: - return imaging.FlipV(img) - case RotatedCWMirrored: - return imaging.Transpose(img) - case RotatedCCW: - return imaging.Rotate270(img) - case RotatedCCWMirrored: - return imaging.Transverse(img) - case RotatedCW: - return imaging.Rotate90(img) - default: - return img - } -} - -func getImageOrientation(input io.Reader) (int, error) { - exifData, err := exif.Decode(input) + orientation, err := imaging.GetImageOrientation(imgData) if err != nil { - return Upright, err + mlog.Debug("GetImageOrientation failed", mlog.Err(err)) } + img = imaging.MakeImageUpright(img, orientation) - tag, err := exifData.Get("Orientation") - if err != nil { - return Upright, err - } - - orientation, err := tag.Int(0) - if err != nil { - return Upright, err - } - - return orientation, nil + return img, release, nil } func (a *App) generateThumbnailImage(img image.Image, thumbnailPath string) { - buf := new(bytes.Buffer) - if err := jpeg.Encode(buf, genThumbnail(img), &jpeg.Options{Quality: 90}); err != nil { + var buf bytes.Buffer + if err := a.srv.imgEncoder.EncodeJPEG(&buf, imaging.GenerateThumbnail(img, imageThumbnailWidth, imageThumbnailHeight), jpegEncQuality); err != nil { mlog.Error("Unable to encode image as jpeg", mlog.String("path", thumbnailPath), mlog.Err(err)) return } - if _, err := a.WriteFile(buf, thumbnailPath); err != nil { + if _, err := a.WriteFile(&buf, thumbnailPath); err != nil { mlog.Error("Unable to upload thumbnail", mlog.String("path", thumbnailPath), mlog.Err(err)) return } } func (a *App) generatePreviewImage(img image.Image, previewPath string) { - preview := genPreview(img) + var buf bytes.Buffer + preview := imaging.GeneratePreview(img, imagePreviewWidth) - buf := new(bytes.Buffer) - - if err := jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90}); err != nil { + if err := a.srv.imgEncoder.EncodeJPEG(&buf, preview, jpegEncQuality); err != nil { mlog.Error("Unable to encode image as preview jpg", mlog.Err(err), mlog.String("path", previewPath)) return } - if _, err := a.WriteFile(buf, previewPath); err != nil { + if _, err := a.WriteFile(&buf, previewPath); err != nil { mlog.Error("Unable to upload preview", mlog.Err(err), mlog.String("path", previewPath)) return } @@ -1186,18 +1117,26 @@ func (a *App) generatePreviewImage(img image.Image, previewPath string) { // will save fileinfo with the preview added func (a *App) generateMiniPreview(fi *model.FileInfo) { if fi.IsImage() && fi.MiniPreview == nil { - data, err := a.ReadFile(fi.Path) + file, err := a.FileReader(fi.Path) if err != nil { - mlog.Error("error reading image file", mlog.Err(err)) + mlog.Debug("error reading image file", mlog.Err(err)) return } - img, _, _ := prepareImage(data) - if img == nil { + defer file.Close() + img, release, imgErr := prepareImage(a.srv.imgDecoder, file) + if imgErr != nil { + mlog.Debug("generateMiniPreview: prepareImage failed", mlog.Err(imgErr)) return } - fi.MiniPreview = model.GenerateMiniPreviewImage(img) + defer release() + if miniPreview, err := imaging.GenerateMiniPreviewImage(img, + miniPreviewImageWidth, miniPreviewImageHeight, jpegEncQuality); err != nil { + mlog.Info("Unable to generate mini preview image", mlog.Err(err)) + } else { + fi.MiniPreview = &miniPreview + } if _, appErr := a.Srv().Store.FileInfo().Upsert(fi); appErr != nil { - mlog.Error("creating mini preview failed", mlog.Err(appErr)) + mlog.Debug("creating mini preview failed", mlog.Err(appErr)) } else { a.Srv().Store.FileInfo().InvalidateFileInfosForPostCache(fi.PostId, false) } @@ -1411,8 +1350,8 @@ func (a *App) ExtractContentFromFileInfo(fileInfo *model.FileInfo) error { return errors.Wrap(err, "failed to extract file content") } if text != "" { - if len(text) > MaxContentExtractionSize { - text = text[0:MaxContentExtractionSize] + if len(text) > maxContentExtractionSize { + text = text[0:maxContentExtractionSize] } if storeErr := a.Srv().Store.FileInfo().SetContent(fileInfo.Id, text); storeErr != nil { return errors.Wrap(storeErr, "failed to save the extracted file content") diff --git a/app/image.go b/app/image.go index a2f8ea10b2..0d5089e0a2 100644 --- a/app/image.go +++ b/app/image.go @@ -4,47 +4,29 @@ package app import ( - "image" + "fmt" + "io" - "github.com/disintegration/imaging" + "github.com/mattermost/mattermost-server/v5/app/imaging" ) -func genThumbnail(img image.Image) image.Image { - thumb := img - w := img.Bounds().Dx() - h := img.Bounds().Dy() - - if h > ImageThumbnailHeight || w > ImageThumbnailWidth { - ratio := float64(h) / float64(w) - if ratio < ImageThumbnailRatio { - // we pre-calculate the thumbnail's width to make sure we are not upscaling. - targetWidth := int(float64(ImageThumbnailHeight) * float64(w) / float64(h)) - if targetWidth <= w { - thumb = imaging.Resize(img, 0, ImageThumbnailHeight, imaging.Lanczos) - } else { - thumb = imaging.Resize(img, ImageThumbnailWidth, 0, imaging.Lanczos) - } - } else { - // we pre-calculate the thumbnail's height to make sure we are not upscaling. - targetHeight := int(float64(ImageThumbnailWidth) * float64(h) / float64(w)) - if targetHeight <= h { - thumb = imaging.Resize(img, ImageThumbnailWidth, 0, imaging.Lanczos) - } else { - thumb = imaging.Resize(img, 0, ImageThumbnailHeight, imaging.Lanczos) - } - } +func checkImageResolutionLimit(w, h int) error { + // This casting is done to prevent overflow on 32 bit systems (not needed + // in 64 bits systems because images can't have more than 32 bits height or + // width) + imageRes := int64(w) * int64(h) + if imageRes > maxImageRes { + return fmt.Errorf("image resolution is too high: %d, max allowed is %d", imageRes, maxImageRes) } - return thumb + return nil } -func genPreview(img image.Image) image.Image { - preview := img - w := img.Bounds().Dx() - - if w > ImagePreviewWidth { - preview = imaging.Resize(img, ImagePreviewWidth, 0, imaging.Lanczos) +func checkImageLimits(imageData io.Reader) error { + w, h, err := imaging.GetDimensions(imageData) + if err != nil { + return fmt.Errorf("failed to get image dimensions: %w", err) } - return preview + return checkImageResolutionLimit(w, h) } diff --git a/app/imaging/decode.go b/app/imaging/decode.go new file mode 100644 index 0000000000..7ee27895d7 --- /dev/null +++ b/app/imaging/decode.go @@ -0,0 +1,146 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "sync" + + _ "github.com/oov/psd" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" +) + +// DecoderOptions holds configuration options for an image decoder. +type DecoderOptions struct { + // The level of concurrency for the decoder. This defines a limit on the + // number of concurrently running encoding goroutines. + ConcurrencyLevel int +} + +func (o *DecoderOptions) validate() error { + if o.ConcurrencyLevel < 0 { + return errors.New("ConcurrencyLevel must be non-negative") + } + return nil +} + +// Decoder holds the necessary state to decode images. +// This is safe to be used from multiple goroutines. +type Decoder struct { + sem chan struct{} + opts DecoderOptions +} + +// NewDecoder creates and returns a new image decoder with the given options. +func NewDecoder(opts DecoderOptions) (*Decoder, error) { + var d Decoder + if err := opts.validate(); err != nil { + return nil, fmt.Errorf("imaging: error validating decoder options: %w", err) + } + if opts.ConcurrencyLevel > 0 { + d.sem = make(chan struct{}, opts.ConcurrencyLevel) + } + d.opts = opts + return &d, nil +} + +// Decode decodes the given encoded data and returns the decoded image. +func (d *Decoder) Decode(rd io.Reader) (img image.Image, format string, err error) { + if d.opts.ConcurrencyLevel != 0 { + d.sem <- struct{}{} + defer func() { <-d.sem }() + } + + img, format, err = image.Decode(rd) + if err != nil { + return nil, "", fmt.Errorf("imaging: failed to decode image: %w", err) + } + + return img, format, nil +} + +// DecodeMemBounded works similarly to Decode but also returns a release function that +// must be called when access to the raw image is not needed anymore. +// This sets the raw image data pointer to nil in an attempt to help the GC to re-use the underlying data as soon as possible. +func (d *Decoder) DecodeMemBounded(rd io.Reader) (img image.Image, format string, releaseFunc func(), err error) { + if d.opts.ConcurrencyLevel != 0 { + d.sem <- struct{}{} + defer func() { + if err != nil { + <-d.sem + } + }() + } + + img, format, err = image.Decode(rd) + if err != nil { + return nil, "", nil, fmt.Errorf("imaging: failed to decode image: %w", err) + } + + var once sync.Once + releaseFunc = func() { + if d.opts.ConcurrencyLevel == 0 { + return + } + once.Do(func() { + if img != nil { + releaseImageData(img) + } + <-d.sem + }) + } + + return img, format, releaseFunc, nil +} + +// DecodeConfig returns the image config for the given data. +func (d *Decoder) DecodeConfig(rd io.Reader) (image.Config, string, error) { + img, format, err := image.DecodeConfig(rd) + if err != nil { + return image.Config{}, "", fmt.Errorf("imaging: failed to decode image config: %w", err) + } + return img, format, nil +} + +// GetDimensions returns the dimensions for the given encoded image data. +func GetDimensions(imageData io.Reader) (int, int, error) { + cfg, _, err := image.DecodeConfig(imageData) + if seeker, ok := imageData.(io.ReadSeeker); ok { + defer seeker.Seek(0, 0) + } + return cfg.Width, cfg.Height, err +} + +// This is only needed to try and simplify GC work. +func releaseImageData(img image.Image) { + switch raw := img.(type) { + case *image.Alpha: + raw.Pix = nil + case *image.Alpha16: + raw.Pix = nil + case *image.Gray: + raw.Pix = nil + case *image.Gray16: + raw.Pix = nil + case *image.NRGBA: + raw.Pix = nil + case *image.NRGBA64: + raw.Pix = nil + case *image.Paletted: + raw.Pix = nil + case *image.RGBA: + raw.Pix = nil + case *image.RGBA64: + raw.Pix = nil + default: + return + } +} diff --git a/app/imaging/decode_bench_test.go b/app/imaging/decode_bench_test.go new file mode 100644 index 0000000000..8a78ad7a6d --- /dev/null +++ b/app/imaging/decode_bench_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "fmt" + "os" + "runtime" + "sync" + "testing" + + "github.com/mattermost/mattermost-server/v5/utils/fileutils" + + "github.com/stretchr/testify/require" +) + +func BenchmarkDecoderDecode(b *testing.B) { + n := runtime.NumCPU() + for k := 1; k <= n; k++ { + b.Run(fmt.Sprintf("%d concurrency", k), func(b *testing.B) { + d, err := NewDecoder(DecoderOptions{ + ConcurrencyLevel: k, + }) + require.NotNil(b, d) + require.NoError(b, err) + + imgDir, ok := fileutils.FindDir("tests") + require.True(b, ok) + + b.ResetTimer() + + var wg sync.WaitGroup + for i := 0; i < b.N; i++ { + b.StopTimer() + wg.Add(1) + imgFile, err := os.Open(imgDir + "/fill_test_opaque.png") + require.NoError(b, err) + defer imgFile.Close() + b.StartTimer() + go func() { + defer wg.Done() + img, _, err := d.Decode(imgFile) + require.NoError(b, err) + require.NotNil(b, img) + }() + } + + wg.Wait() + }) + } +} + +func BenchmarkDecoderDecodeMemBounded(b *testing.B) { + n := runtime.NumCPU() + for k := 1; k <= n; k++ { + b.Run(fmt.Sprintf("%d concurrency", k), func(b *testing.B) { + d, err := NewDecoder(DecoderOptions{ + ConcurrencyLevel: k, + }) + require.NotNil(b, d) + require.NoError(b, err) + + imgDir, ok := fileutils.FindDir("tests") + require.True(b, ok) + + b.ResetTimer() + + var wg sync.WaitGroup + for i := 0; i < b.N; i++ { + b.StopTimer() + wg.Add(1) + imgFile, err := os.Open(imgDir + "/fill_test_opaque.png") + require.NoError(b, err) + defer imgFile.Close() + b.StartTimer() + go func() { + defer wg.Done() + img, _, release, err := d.DecodeMemBounded(imgFile) + require.NoError(b, err) + require.NotNil(b, img) + release() + }() + } + + wg.Wait() + }) + } +} diff --git a/app/imaging/decode_test.go b/app/imaging/decode_test.go new file mode 100644 index 0000000000..6dc98792c1 --- /dev/null +++ b/app/imaging/decode_test.go @@ -0,0 +1,225 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "bytes" + "os" + "sync" + "testing" + + "github.com/mattermost/mattermost-server/v5/utils/fileutils" + + "github.com/stretchr/testify/require" +) + +func TestNewDecoder(t *testing.T) { + t.Run("invalid options", func(t *testing.T) { + d, err := NewDecoder(DecoderOptions{ + ConcurrencyLevel: -1, + }) + require.Nil(t, d) + require.Error(t, err) + }) + + t.Run("empty options", func(t *testing.T) { + d, err := NewDecoder(DecoderOptions{}) + require.NotNil(t, d) + require.NoError(t, err) + require.Nil(t, d.sem) + }) + + t.Run("valid options", func(t *testing.T) { + d, err := NewDecoder(DecoderOptions{ + ConcurrencyLevel: 4, + }) + require.NotNil(t, d) + require.NoError(t, err) + require.NotNil(t, d.sem) + require.Equal(t, 4, cap(d.sem)) + }) +} + +func TestDecoderDecode(t *testing.T) { + t.Run("default options", func(t *testing.T) { + d, err := NewDecoder(DecoderOptions{}) + require.NotNil(t, d) + require.NoError(t, err) + + imgDir, ok := fileutils.FindDir("tests") + require.True(t, ok) + + imgFile, err := os.Open(imgDir + "/test.png") + require.NoError(t, err) + require.NotNil(t, imgFile) + defer imgFile.Close() + + img, format, err := d.Decode(imgFile) + require.NoError(t, err) + require.NotNil(t, img) + require.Equal(t, "png", format) + }) + + t.Run("concurrency bounded", func(t *testing.T) { + d, err := NewDecoder(DecoderOptions{ + ConcurrencyLevel: 1, + }) + require.NotNil(t, d) + require.NoError(t, err) + + imgDir, ok := fileutils.FindDir("tests") + require.True(t, ok) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + + imgFile, err := os.Open(imgDir + "/test.png") + require.NoError(t, err) + require.NotNil(t, imgFile) + defer imgFile.Close() + + img, format, err := d.Decode(imgFile) + require.NoError(t, err) + require.NotNil(t, img) + require.Equal(t, "png", format) + }() + + go func() { + defer wg.Done() + + imgFile, err := os.Open(imgDir + "/test.png") + require.NoError(t, err) + require.NotNil(t, imgFile) + defer imgFile.Close() + + img, format, err := d.Decode(imgFile) + require.NoError(t, err) + require.NotNil(t, img) + require.Equal(t, "png", format) + }() + + wg.Wait() + require.Empty(t, d.sem) + }) +} + +func TestDecoderDecodeMemBounded(t *testing.T) { + t.Run("concurrency bounded", func(t *testing.T) { + d, err := NewDecoder(DecoderOptions{ + ConcurrencyLevel: 1, + }) + require.NotNil(t, d) + require.NoError(t, err) + + imgDir, ok := fileutils.FindDir("tests") + require.True(t, ok) + + imgFile, err := os.Open(imgDir + "/test.png") + require.NoError(t, err) + require.NotNil(t, imgFile) + defer imgFile.Close() + + var wg sync.WaitGroup + wg.Add(2) + + var lock sync.Mutex + + go func() { + defer wg.Done() + img, format, release, err := d.DecodeMemBounded(imgFile) + lock.Lock() + imgFile.Seek(0, 0) + lock.Unlock() + require.NoError(t, err) + defer release() + require.NotNil(t, img) + require.Equal(t, "png", format) + require.NotNil(t, release) + require.NotEmpty(t, d.sem) + }() + + go func() { + defer wg.Done() + img, format, release, err := d.DecodeMemBounded(imgFile) + lock.Lock() + imgFile.Seek(0, 0) + lock.Unlock() + require.NoError(t, err) + defer release() + require.NotNil(t, img) + require.Equal(t, "png", format) + require.NotNil(t, release) + require.NotEmpty(t, d.sem) + }() + + wg.Wait() + require.Empty(t, d.sem) + }) + + t.Run("decode error", func(t *testing.T) { + d, err := NewDecoder(DecoderOptions{ + ConcurrencyLevel: 1, + }) + require.NotNil(t, d) + require.NoError(t, err) + + var data bytes.Buffer + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + img, format, release, err := d.DecodeMemBounded(&data) + require.Error(t, err) + require.Nil(t, img) + require.Empty(t, format) + require.Nil(t, release) + }() + + go func() { + defer wg.Done() + img, format, release, err := d.DecodeMemBounded(&data) + require.Error(t, err) + require.Nil(t, img) + require.Empty(t, format) + require.Nil(t, release) + }() + + wg.Wait() + require.Empty(t, d.sem) + }) + + t.Run("multiple releases", func(t *testing.T) { + d, err := NewDecoder(DecoderOptions{ + ConcurrencyLevel: 1, + }) + require.NotNil(t, d) + require.NoError(t, err) + + imgDir, ok := fileutils.FindDir("tests") + require.True(t, ok) + + imgFile, err := os.Open(imgDir + "/test.png") + require.NoError(t, err) + require.NotNil(t, imgFile) + defer imgFile.Close() + + img, format, release, err := d.DecodeMemBounded(imgFile) + require.NoError(t, err) + require.NotNil(t, img) + require.Equal(t, "png", format) + require.NotNil(t, release) + require.Len(t, d.sem, 1) + release() + require.Empty(t, d.sem) + release() + require.Empty(t, d.sem) + release() + require.Empty(t, d.sem) + }) +} diff --git a/app/imaging/encode.go b/app/imaging/encode.go new file mode 100644 index 0000000000..1afb3159d9 --- /dev/null +++ b/app/imaging/encode.go @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "errors" + "fmt" + "image" + "io" + + "image/jpeg" + "image/png" +) + +// EncoderOptions holds configuration options for an image encoder. +type EncoderOptions struct { + // The level of concurrency for the encoder. This defines a limit on the + // number of concurrently running encoding goroutines. + ConcurrencyLevel int +} + +func (o *EncoderOptions) validate() error { + if o.ConcurrencyLevel < 0 { + return errors.New("ConcurrencyLevel must be non-negative") + } + return nil +} + +// Decoder holds the necessary state to encode images. +// This is safe to be used from multiple goroutines. +type Encoder struct { + sem chan struct{} + opts EncoderOptions + pngEncoder *png.Encoder +} + +// NewEncoder creates and returns a new image encoder with the given options. +func NewEncoder(opts EncoderOptions) (*Encoder, error) { + var e Encoder + if err := opts.validate(); err != nil { + return nil, fmt.Errorf("imaging: error validating encoder options: %w", err) + } + if opts.ConcurrencyLevel > 0 { + e.sem = make(chan struct{}, opts.ConcurrencyLevel) + } + e.opts = opts + e.pngEncoder = &png.Encoder{} + return &e, nil +} + +// EncodeJPEG encodes the given image in JPEG format and writes the data to +// the passed writer. +func (e *Encoder) EncodeJPEG(wr io.Writer, img image.Image, quality int) error { + if e.opts.ConcurrencyLevel > 0 { + e.sem <- struct{}{} + defer func() { + <-e.sem + }() + } + + var encOpts jpeg.Options + encOpts.Quality = quality + if err := jpeg.Encode(wr, img, &encOpts); err != nil { + return fmt.Errorf("imaging: failed to encode jpeg: %w", err) + } + + return nil +} + +// EncodePNG encodes the given image in PNG format and writes the data to +// the passed writer. +func (e *Encoder) EncodePNG(wr io.Writer, img image.Image) error { + if e.opts.ConcurrencyLevel > 0 { + e.sem <- struct{}{} + defer func() { + <-e.sem + }() + } + + if err := e.pngEncoder.Encode(wr, img); err != nil { + return fmt.Errorf("imaging: failed to encode png: %w", err) + } + + return nil +} diff --git a/app/imaging/encode_test.go b/app/imaging/encode_test.go new file mode 100644 index 0000000000..8dfc3fd49e --- /dev/null +++ b/app/imaging/encode_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "bytes" + "image" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewEncoder(t *testing.T) { + t.Run("invalid options", func(t *testing.T) { + e, err := NewEncoder(EncoderOptions{ + ConcurrencyLevel: -1, + }) + require.Nil(t, e) + require.Error(t, err) + }) + + t.Run("empty options", func(t *testing.T) { + e, err := NewEncoder(EncoderOptions{}) + require.NotNil(t, e) + require.NoError(t, err) + require.Nil(t, e.sem) + }) + + t.Run("valid options", func(t *testing.T) { + e, err := NewEncoder(EncoderOptions{ + ConcurrencyLevel: 4, + }) + require.NotNil(t, e) + require.NoError(t, err) + require.NotNil(t, e.sem) + require.Equal(t, 4, cap(e.sem)) + }) +} + +func TestEncoderEncode(t *testing.T) { + t.Run("default options", func(t *testing.T) { + e, err := NewEncoder(EncoderOptions{}) + require.NotNil(t, e) + require.NoError(t, err) + + var buf bytes.Buffer + rawImg := image.NewRGBA(image.Rect(0, 0, 1280, 1024)) + err = e.EncodePNG(&buf, rawImg) + require.NoError(t, err) + require.NotEmpty(t, buf) + + err = e.EncodeJPEG(&buf, rawImg, 50) + require.NoError(t, err) + require.NotEmpty(t, buf) + }) + + t.Run("concurrency bounded", func(t *testing.T) { + e, err := NewEncoder(EncoderOptions{ + ConcurrencyLevel: 1, + }) + require.NotNil(t, e) + require.NoError(t, err) + + rawImg := image.NewRGBA(image.Rect(0, 0, 1280, 1024)) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + var buf bytes.Buffer + err := e.EncodePNG(&buf, rawImg) + require.NoError(t, err) + require.NotEmpty(t, buf) + }() + + go func() { + defer wg.Done() + var buf bytes.Buffer + err := e.EncodeJPEG(&buf, rawImg, 50) + require.NoError(t, err) + require.NotEmpty(t, buf) + }() + + wg.Wait() + require.Empty(t, e.sem) + }) +} diff --git a/app/imaging/orientation.go b/app/imaging/orientation.go new file mode 100644 index 0000000000..5a2df84bfe --- /dev/null +++ b/app/imaging/orientation.go @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "image" + "io" + + "github.com/disintegration/imaging" + "github.com/rwcarlsen/goexif/exif" +) + +const ( + /* + EXIF Image Orientations + 1 2 3 4 5 6 7 8 + + 888888 888888 88 88 8888888888 88 88 8888888888 + 88 88 88 88 88 88 88 88 88 88 88 88 + 8888 8888 8888 8888 88 8888888888 8888888888 88 + 88 88 88 88 + 88 88 888888 888888 + */ + Upright = iota + 1 + UprightMirrored + UpsideDown + UpsideDownMirrored + RotatedCWMirrored + RotatedCCW + RotatedCCWMirrored + RotatedCW +) + +// MakeImageUpright changes the orientation of the given image. +func MakeImageUpright(img image.Image, orientation int) image.Image { + switch orientation { + case UprightMirrored: + return imaging.FlipH(img) + case UpsideDown: + return imaging.Rotate180(img) + case UpsideDownMirrored: + return imaging.FlipV(img) + case RotatedCWMirrored: + return imaging.Transpose(img) + case RotatedCCW: + return imaging.Rotate270(img) + case RotatedCCWMirrored: + return imaging.Transverse(img) + case RotatedCW: + return imaging.Rotate90(img) + default: + return img + } +} + +// GetImageOrientation reads the input data and returns the EXIF encoded +// image orientation. +func GetImageOrientation(input io.Reader) (int, error) { + exifData, err := exif.Decode(input) + if err != nil { + return Upright, err + } + + tag, err := exifData.Get("Orientation") + if err != nil { + return Upright, err + } + + orientation, err := tag.Int(0) + if err != nil { + return Upright, err + } + + return orientation, nil +} diff --git a/app/imaging/preview.go b/app/imaging/preview.go new file mode 100644 index 0000000000..83cf246db5 --- /dev/null +++ b/app/imaging/preview.go @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + + "github.com/disintegration/imaging" +) + +// GeneratePreview generates the preview for the given image. +func GeneratePreview(img image.Image, width int) image.Image { + preview := img + w := img.Bounds().Dx() + + if w > width { + preview = imaging.Resize(img, width, 0, imaging.Lanczos) + } + + return preview +} + +// GenerateThumbnail generates the thumbnail for the given image. +func GenerateThumbnail(img image.Image, width, height int) image.Image { + thumb := img + w := img.Bounds().Dx() + h := img.Bounds().Dy() + expectedRatio := float64(height) / float64(width) + + if h > height || w > width { + ratio := float64(h) / float64(w) + if ratio < expectedRatio { + // we pre-calculate the thumbnail's width to make sure we are not upscaling. + targetWidth := int(float64(height) * float64(w) / float64(h)) + if targetWidth <= w { + thumb = imaging.Resize(img, 0, height, imaging.Lanczos) + } else { + thumb = imaging.Resize(img, width, 0, imaging.Lanczos) + } + } else { + // we pre-calculate the thumbnail's height to make sure we are not upscaling. + targetHeight := int(float64(width) * float64(h) / float64(w)) + if targetHeight <= h { + thumb = imaging.Resize(img, width, 0, imaging.Lanczos) + } else { + thumb = imaging.Resize(img, 0, height, imaging.Lanczos) + } + } + } + + return thumb +} + +// GenerateMiniPreviewImage generates the mini preview for the given image. +func GenerateMiniPreviewImage(img image.Image, w, h, q int) ([]byte, error) { + var buf bytes.Buffer + preview := imaging.Resize(img, w, h, imaging.Lanczos) + if err := jpeg.Encode(&buf, preview, &jpeg.Options{Quality: q}); err != nil { + return nil, fmt.Errorf("failed to encode image to JPEG format: %w", err) + } + return buf.Bytes(), nil +} diff --git a/app/svg.go b/app/imaging/svg.go similarity index 89% rename from app/svg.go rename to app/imaging/svg.go index 7fdc4472c5..c45c134a5a 100644 --- a/app/svg.go +++ b/app/imaging/svg.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package app +package imaging import ( "encoding/xml" @@ -12,12 +12,14 @@ import ( "github.com/pkg/errors" ) +// SVGInfo holds information for a SVG image. type SVGInfo struct { Width int Height int } -func parseSVG(svgReader io.Reader) (SVGInfo, error) { +// ParseSVG returns information for the given SVG input data. +func ParseSVG(svgReader io.Reader) (SVGInfo, error) { var parsedSVG struct { Width string `xml:"width,attr,omitempty"` Height string `xml:"height,attr,omitempty"` diff --git a/app/svg_test.go b/app/imaging/svg_test.go similarity index 97% rename from app/svg_test.go rename to app/imaging/svg_test.go index 93380910bf..c9e5bbdc5e 100644 --- a/app/svg_test.go +++ b/app/imaging/svg_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package app +package imaging import ( "fmt" @@ -45,7 +45,7 @@ func TestParseValidSVGData(t *testing.T) { generateSVGData(width, height, false, true, true), // missing viewBox, properly formed width & height; alternate format } for index, svg := range validSVGs { - svgInfo, err := parseSVG(svg) + svgInfo, err := ParseSVG(svg) if err != nil { t.Errorf("Should be able to parse SVG attributes at index %d, but was not able to: err = %v", index, err) } else { @@ -68,7 +68,7 @@ func TestParseInvalidSVGData(t *testing.T) { generateSVGData(width, 0, false, true, false), // missing viewBox, malformed height, properly formed width } for index, svg := range invalidSVGs { - _, err := parseSVG(svg) + _, err := ParseSVG(svg) if err == nil { t.Errorf("Should not be able to parse SVG attributes at index %d, but was definitely able to!", index) } diff --git a/app/imaging/utils.go b/app/imaging/utils.go new file mode 100644 index 0000000000..d119b07676 --- /dev/null +++ b/app/imaging/utils.go @@ -0,0 +1,147 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "image" + "image/color" + + "github.com/disintegration/imaging" +) + +type rawImg interface { + Set(x, y int, c color.Color) + Opaque() bool +} + +func isFullyTransparent(c color.Color) bool { + // TODO: This can be optimized by checking the color type and + // only extract the needed alpha value. + _, _, _, a := c.RGBA() + return a == 0 +} + +// FillImageTransparency fills in-place all the fully transparent pixels of the +// input image with the given color. +func FillImageTransparency(img image.Image, c color.Color) { + var i rawImg + + bounds := img.Bounds() + + fillFunc := func() { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + if isFullyTransparent(img.At(x, y)) { + i.Set(x, y, c) + } + } + } + } + + switch raw := img.(type) { + case *image.Alpha: + i = raw + case *image.Alpha16: + i = raw + case *image.Gray: + i = raw + case *image.Gray16: + i = raw + case *image.NRGBA: + i = raw + col := color.NRGBAModel.Convert(c).(color.NRGBA) + fillFunc = func() { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + i := raw.PixOffset(x, y) + if raw.Pix[i+3] == 0x00 { + raw.Pix[i] = col.R + raw.Pix[i+1] = col.G + raw.Pix[i+2] = col.B + raw.Pix[i+3] = col.A + } + } + } + } + case *image.NRGBA64: + i = raw + col := color.NRGBA64Model.Convert(c).(color.NRGBA64) + fillFunc = func() { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + i := raw.PixOffset(x, y) + a := uint16(raw.Pix[i+6])<<8 | uint16(raw.Pix[i+7]) + if a == 0 { + raw.Pix[i] = uint8(col.R >> 8) + raw.Pix[i+1] = uint8(col.R) + raw.Pix[i+2] = uint8(col.G >> 8) + raw.Pix[i+3] = uint8(col.G) + raw.Pix[i+4] = uint8(col.B >> 8) + raw.Pix[i+5] = uint8(col.B) + raw.Pix[i+6] = uint8(col.A >> 8) + raw.Pix[i+7] = uint8(col.A) + } + } + } + } + case *image.Paletted: + i = raw + fillFunc = func() { + for i := range raw.Palette { + if isFullyTransparent(raw.Palette[i]) { + raw.Palette[i] = c + } + } + } + case *image.RGBA: + i = raw + col := color.RGBAModel.Convert(c).(color.RGBA) + fillFunc = func() { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + i := raw.PixOffset(x, y) + if raw.Pix[i+3] == 0x00 { + raw.Pix[i] = col.R + raw.Pix[i+1] = col.G + raw.Pix[i+2] = col.B + raw.Pix[i+3] = col.A + } + } + } + } + case *image.RGBA64: + i = raw + col := color.RGBA64Model.Convert(c).(color.RGBA64) + fillFunc = func() { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + i := raw.PixOffset(x, y) + a := uint16(raw.Pix[i+6])<<8 | uint16(raw.Pix[i+7]) + if a == 0 { + raw.Pix[i] = uint8(col.R >> 8) + raw.Pix[i+1] = uint8(col.R) + raw.Pix[i+2] = uint8(col.G >> 8) + raw.Pix[i+3] = uint8(col.G) + raw.Pix[i+4] = uint8(col.B >> 8) + raw.Pix[i+5] = uint8(col.B) + raw.Pix[i+6] = uint8(col.A >> 8) + raw.Pix[i+7] = uint8(col.A) + } + } + } + } + default: + return + } + + if !i.Opaque() { + fillFunc() + } +} + +// FillCenter creates an image with the specified dimensions and fills it with +// the centered and scaled source image. +func FillCenter(img image.Image, w, h int) *image.NRGBA { + return imaging.Fill(img, w, h, imaging.Center, imaging.Lanczos) +} diff --git a/app/imaging/utils_bench_test.go b/app/imaging/utils_bench_test.go new file mode 100644 index 0000000000..a69aabe175 --- /dev/null +++ b/app/imaging/utils_bench_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "image" + "image/color" + "image/draw" + "testing" +) + +func fillImageTransparencyOld(img image.Image, c color.Color) { + dst := image.NewRGBA(img.Bounds()) + draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) + draw.Draw(dst, dst.Bounds(), img, img.Bounds().Min, draw.Over) +} + +func fullyOpaqueGen(w, h int) func() image.Image { + return func() image.Image { + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) + return dst + } +} + +func partiallyOpaqueGen(w, h int) func() image.Image { + return func() image.Image { + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(dst, image.Rect(0, 0, w/2, h/2), image.NewUniform(color.White), image.Point{}, draw.Src) + return dst + } +} + +func fullyTransparentGen(w, h int) func() image.Image { + return func() image.Image { + return image.NewRGBA(image.Rect(0, 0, w, h)) + } +} + +func fullyOpaquePaletteGen(w, h int) func() image.Image { + return func() image.Image { + return image.NewPaletted(image.Rect(0, 0, w, h), []color.Color{image.White}) + } +} + +func fullyTransparentPaletteGen(w, h int) func() image.Image { + return func() image.Image { + return image.NewPaletted(image.Rect(0, 0, w, h), []color.Color{image.Transparent}) + } +} + +var tcs = []struct { + name string + imgGen func() image.Image +}{ + { + "10MPx fully transparent RGBA", + fullyTransparentGen(1000, 1000), + }, + { + "10MPx partially opaque RGBA", + partiallyOpaqueGen(1000, 1000), + }, + { + "10MPx fully opaque RGBA", + fullyOpaqueGen(1000, 1000), + }, + { + "10MPx fully opaque palette", + fullyOpaquePaletteGen(1000, 1000), + }, + { + "10MPx fully transparent palette", + fullyTransparentPaletteGen(1000, 1000), + }, +} + +func BenchmarkFillImageTransparency(b *testing.B) { + for _, tc := range tcs { + b.Run(tc.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + b.StopTimer() + img := tc.imgGen() + b.StartTimer() + FillImageTransparency(img, image.White) + } + }) + } +} + +func BenchmarkFillImageTransparencyOld(b *testing.B) { + for _, tc := range tcs { + b.Run(tc.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + b.StopTimer() + img := tc.imgGen() + b.StartTimer() + fillImageTransparencyOld(img, image.White) + } + }) + } +} diff --git a/app/imaging/utils_test.go b/app/imaging/utils_test.go new file mode 100644 index 0000000000..50ae74d38e --- /dev/null +++ b/app/imaging/utils_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "bytes" + "image/color" + "io/ioutil" + "os" + "testing" + + "github.com/mattermost/mattermost-server/v5/utils/fileutils" + + "github.com/stretchr/testify/require" +) + +func TestFillImageTransparency(t *testing.T) { + tcs := []struct { + name string + inputName string + outputName string + fillColor color.Color + }{ + { + "8-bit Palette", + "fill_test_8bit_palette.png", + "fill_test_8bit_palette_out.png", + color.RGBA{0, 255, 0, 255}, + }, + { + "8-bit RGB", + "fill_test_8bit_rgb.png", + "fill_test_8bit_rgb_out.png", + color.RGBA{0, 255, 0, 255}, + }, + { + "8-bit RGBA", + "fill_test_8bit_rgba.png", + "fill_test_8bit_rgba_out.png", + color.RGBA{0, 255, 0, 255}, + }, + { + "16-bit RGB", + "fill_test_16bit_rgb.png", + "fill_test_16bit_rgb_out.png", + color.RGBA{0, 255, 0, 255}, + }, + { + "16-bit RGBA", + "fill_test_16bit_rgba.png", + "fill_test_16bit_rgba_out.png", + color.RGBA{0, 255, 0, 255}, + }, + } + + imgDir, ok := fileutils.FindDir("tests") + require.True(t, ok) + + e, err := NewEncoder(EncoderOptions{}) + require.NotNil(t, e) + require.NoError(t, err) + + d, err := NewDecoder(DecoderOptions{}) + require.NotNil(t, d) + require.NoError(t, err) + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + inputFile, err := os.Open(imgDir + "/" + tc.inputName) + require.NoError(t, err) + require.NotNil(t, inputFile) + defer inputFile.Close() + + inputImg, format, err := d.Decode(inputFile) + require.NoError(t, err) + require.NotNil(t, inputImg) + require.Equal(t, "png", format) + + expectedBytes, err := ioutil.ReadFile(imgDir + "/" + tc.outputName) + require.NoError(t, err) + + FillImageTransparency(inputImg, tc.fillColor) + + var b bytes.Buffer + err = e.EncodePNG(&b, inputImg) + require.NoError(t, err) + + require.Equal(t, expectedBytes, b.Bytes()) + }) + } + + t.Run("Opaque image", func(t *testing.T) { + inputFile, err := os.Open(imgDir + "/fill_test_opaque.png") + require.NoError(t, err) + require.NotNil(t, inputFile) + defer inputFile.Close() + + inputImg, format, err := d.Decode(inputFile) + require.NoError(t, err) + require.NotNil(t, inputImg) + require.Equal(t, "png", format) + + inputFile.Seek(0, 0) + + expectedImg, format, err := d.Decode(inputFile) + require.NoError(t, err) + require.NotNil(t, expectedImg) + require.Equal(t, "png", format) + + FillImageTransparency(inputImg, color.RGBA{0, 255, 0, 255}) + + require.Equal(t, expectedImg, inputImg) + }) +} diff --git a/app/server.go b/app/server.go index 6761e91c30..8352e947cd 100644 --- a/app/server.go +++ b/app/server.go @@ -38,6 +38,7 @@ import ( "golang.org/x/crypto/acme/autocert" "github.com/mattermost/mattermost-server/v5/app/featureflag" + "github.com/mattermost/mattermost-server/v5/app/imaging" "github.com/mattermost/mattermost-server/v5/app/request" "github.com/mattermost/mattermost-server/v5/audit" "github.com/mattermost/mattermost-server/v5/config" @@ -207,6 +208,9 @@ type Server struct { featureFlagStop chan struct{} featureFlagStopped chan struct{} featureFlagSynchronizerMutex sync.Mutex + + imgDecoder *imaging.Decoder + imgEncoder *imaging.Encoder } func NewServer(options ...Option) (*Server, error) { @@ -245,6 +249,20 @@ func NewServer(options ...Option) (*Server, error) { mlog.Error("Could not initiate logging", mlog.Err(err)) } + var imgErr error + s.imgDecoder, imgErr = imaging.NewDecoder(imaging.DecoderOptions{ + ConcurrencyLevel: runtime.NumCPU(), + }) + if imgErr != nil { + return nil, errors.Wrap(imgErr, "failed to create image decoder") + } + s.imgEncoder, imgErr = imaging.NewEncoder(imaging.EncoderOptions{ + ConcurrencyLevel: runtime.NumCPU(), + }) + if imgErr != nil { + return nil, errors.Wrap(imgErr, "failed to create image encoder") + } + // This is called after initLogging() to avoid a race condition. mlog.Info("Server is initializing...", mlog.String("go_version", runtime.Version())) diff --git a/app/slack.go b/app/slack.go index 5c7c078e9d..627546355e 100644 --- a/app/slack.go +++ b/app/slack.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "fmt" + "image" "mime/multipart" "regexp" "strings" @@ -39,7 +40,13 @@ func (a *App) SlackImport(c *request.Context, fileData multipart.File, fileSize GeneratePreviewImage: a.generatePreviewImage, InvalidateAllCaches: func() { a.srv.InvalidateAllCaches() }, MaxPostSize: func() int { return a.srv.MaxPostSize() }, - PrepareImage: prepareImage, + PrepareImage: func(fileData []byte) (image.Image, func(), error) { + img, release, err := prepareImage(a.srv.imgDecoder, bytes.NewReader(fileData)) + if err != nil { + return nil, nil, err + } + return img, release, err + }, } importer := slackimport.New(a.srv.Store, actions, a.Config()) diff --git a/app/team.go b/app/team.go index ffaa19983f..ee1f469e34 100644 --- a/app/team.go +++ b/app/team.go @@ -9,15 +9,13 @@ import ( "errors" "fmt" "image" - "image/png" "io" "mime/multipart" "net/http" "net/url" "strings" - "github.com/disintegration/imaging" - + "github.com/mattermost/mattermost-server/v5/app/imaging" "github.com/mattermost/mattermost-server/v5/app/request" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/plugin" @@ -1898,21 +1896,11 @@ func (a *App) SetTeamIconFromMultiPartFile(teamID string, file multipart.File) * return model.NewAppError("setTeamIcon", "api.team.set_team_icon.storage.app_error", nil, "", http.StatusNotImplemented) } - // Decode image config first to check dimensions before loading the whole thing into memory later on - config, _, err := image.DecodeConfig(file) - if err != nil { - return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.decode_config.app_error", nil, err.Error(), http.StatusBadRequest) + if limitErr := checkImageLimits(file); limitErr != nil { + return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.check_image_limits.app_error", + nil, limitErr.Error(), http.StatusBadRequest) } - // This casting is done to prevent overflow on 32 bit systems (not needed - // in 64 bits systems because images can't have more than 32 bits height or - // width) - if int64(config.Width)*int64(config.Height) > model.MaxImageSize { - return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.too_large.app_error", nil, "", http.StatusBadRequest) - } - - file.Seek(0, 0) - return a.SetTeamIconFromFile(team, file) } @@ -1923,15 +1911,15 @@ func (a *App) SetTeamIconFromFile(team *model.Team, file io.Reader) *model.AppEr return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.decode.app_error", nil, err.Error(), http.StatusBadRequest) } - orientation, _ := getImageOrientation(file) - img = makeImageUpright(img, orientation) + orientation, _ := imaging.GetImageOrientation(file) + img = imaging.MakeImageUpright(img, orientation) // Scale team icon teamIconWidthAndHeight := 128 - img = imaging.Fill(img, teamIconWidthAndHeight, teamIconWidthAndHeight, imaging.Center, imaging.Lanczos) + img = imaging.FillCenter(img, teamIconWidthAndHeight, teamIconWidthAndHeight) buf := new(bytes.Buffer) - err = png.Encode(buf, img) + err = a.srv.imgEncoder.EncodePNG(buf, img) if err != nil { return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.encode.app_error", nil, err.Error(), http.StatusInternalServerError) } diff --git a/app/upload.go b/app/upload.go index c03a06083a..0ff12f9c43 100644 --- a/app/upload.go +++ b/app/upload.go @@ -265,14 +265,11 @@ func (a *App) UploadData(c *request.Context, us *model.UploadSession, rd io.Read // image post-processing if info.IsImage() { - // Check dimensions before loading the whole thing into memory later on - // This casting is done to prevent overflow on 32 bit systems (not needed - // in 64 bits systems because images can't have more than 32 bits height or - // width) - if int64(info.Width)*int64(info.Height) > MaxImageSize { + if limitErr := checkImageResolutionLimit(info.Width, info.Height); limitErr != nil { return nil, model.NewAppError("uploadData", "app.upload.upload_data.large_image.app_error", map[string]interface{}{"Filename": us.Filename, "Width": info.Width, "Height": info.Height}, "", http.StatusBadRequest) } + nameWithoutExtension := info.Name[:strings.LastIndex(info.Name, ".")] info.PreviewPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_preview.jpg" info.ThumbnailPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_thumb.jpg" diff --git a/app/user.go b/app/user.go index d0049e20e4..c298e82ab0 100644 --- a/app/user.go +++ b/app/user.go @@ -14,8 +14,6 @@ import ( "image" "image/color" "image/draw" - _ "image/gif" - _ "image/jpeg" "image/png" "io" "io/ioutil" @@ -25,10 +23,10 @@ import ( "strconv" "strings" - "github.com/disintegration/imaging" "github.com/golang/freetype" "github.com/golang/freetype/truetype" + "github.com/mattermost/mattermost-server/v5/app/imaging" "github.com/mattermost/mattermost-server/v5/app/request" "github.com/mattermost/mattermost-server/v5/einterfaces" "github.com/mattermost/mattermost-server/v5/model" @@ -894,39 +892,29 @@ func (a *App) SetProfileImage(userID string, imageData *multipart.FileHeader) *m } func (a *App) SetProfileImageFromMultiPartFile(userID string, file multipart.File) *model.AppError { - // Decode image config first to check dimensions before loading the whole thing into memory later on - config, _, err := image.DecodeConfig(file) - if err != nil { - return model.NewAppError("SetProfileImage", "api.user.upload_profile_user.decode_config.app_error", nil, err.Error(), http.StatusBadRequest) + if limitErr := checkImageLimits(file); limitErr != nil { + return model.NewAppError("SetProfileImage", "api.user.upload_profile_user.check_image_limits.app_error", nil, "", http.StatusBadRequest) } - // This casting is done to prevent overflow on 32 bit systems (not needed - // in 64 bits systems because images can't have more than 32 bits height or - // width) - if int64(config.Width)*int64(config.Height) > model.MaxImageSize { - return model.NewAppError("SetProfileImage", "api.user.upload_profile_user.too_large.app_error", nil, "", http.StatusBadRequest) - } - - file.Seek(0, 0) return a.SetProfileImageFromFile(userID, file) } func (a *App) AdjustImage(file io.Reader) (*bytes.Buffer, *model.AppError) { // Decode image into Image object - img, _, err := image.Decode(file) + img, _, err := a.srv.imgDecoder.Decode(file) if err != nil { return nil, model.NewAppError("SetProfileImage", "api.user.upload_profile_user.decode.app_error", nil, err.Error(), http.StatusBadRequest) } - orientation, _ := getImageOrientation(file) - img = makeImageUpright(img, orientation) + orientation, _ := imaging.GetImageOrientation(file) + img = imaging.MakeImageUpright(img, orientation) // Scale profile image profileWidthAndHeight := 128 - img = imaging.Fill(img, profileWidthAndHeight, profileWidthAndHeight, imaging.Center, imaging.Lanczos) + img = imaging.FillCenter(img, profileWidthAndHeight, profileWidthAndHeight) buf := new(bytes.Buffer) - err = png.Encode(buf, img) + err = a.srv.imgEncoder.EncodePNG(buf, img) if err != nil { return nil, model.NewAppError("SetProfileImage", "api.user.upload_profile_user.encode.app_error", nil, err.Error(), http.StatusInternalServerError) } diff --git a/i18n/en.json b/i18n/en.json index 2792959212..809a666214 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3011,12 +3011,12 @@ "translation": "Empty array under 'image' in request." }, { - "id": "api.team.set_team_icon.decode.app_error", - "translation": "Could not decode team icon." + "id": "api.team.set_team_icon.check_image_limits.app_error", + "translation": "Image limits check failed. Resolution is too high." }, { - "id": "api.team.set_team_icon.decode_config.app_error", - "translation": "Could not decode team icon metadata." + "id": "api.team.set_team_icon.decode.app_error", + "translation": "Could not decode team icon." }, { "id": "api.team.set_team_icon.encode.app_error", @@ -4199,12 +4199,12 @@ "translation": "Empty array under 'image' in request." }, { - "id": "api.user.upload_profile_user.decode.app_error", - "translation": "Could not decode profile image." + "id": "api.user.upload_profile_user.check_image_limits.app_error", + "translation": "Image limits check failed. Resolution is too high." }, { - "id": "api.user.upload_profile_user.decode_config.app_error", - "translation": "Could not save profile image. File does not appear to be a valid image." + "id": "api.user.upload_profile_user.decode.app_error", + "translation": "Could not decode profile image." }, { "id": "api.user.upload_profile_user.encode.app_error", @@ -6743,12 +6743,12 @@ "translation": "Failed to close user index." }, { - "id": "brand.save_brand_image.decode.app_error", - "translation": "Unable to decode the image data." + "id": "brand.save_brand_image.check_image_limits.app_error", + "translation": "Image limits check failed. Resolution is too high." }, { - "id": "brand.save_brand_image.decode_config.app_error", - "translation": "Unable to get image metadata." + "id": "brand.save_brand_image.decode.app_error", + "translation": "Unable to decode the image data." }, { "id": "brand.save_brand_image.encode.app_error", @@ -6762,10 +6762,6 @@ "id": "brand.save_brand_image.save_image.app_error", "translation": "Unable to write the image file to your file storage. Please check your connection and try again." }, - { - "id": "brand.save_brand_image.too_large.app_error", - "translation": "Unable to read the image file. Make sure the image size is less than 2 MB and try again." - }, { "id": "cli.license.critical", "translation": "Feature requires an upgrade to Enterprise Edition and the inclusion of a license key. Please contact your System Administrator." diff --git a/model/file_info.go b/model/file_info.go index f86c22b506..2bad90233a 100644 --- a/model/file_info.go +++ b/model/file_info.go @@ -4,20 +4,14 @@ package model import ( - "bytes" "encoding/json" "image" "image/gif" - "image/jpeg" "io" "mime" "net/http" "path/filepath" "strings" - - "github.com/disintegration/imaging" - - "github.com/mattermost/mattermost-server/v5/shared/mlog" ) const ( @@ -162,19 +156,6 @@ func NewInfo(name string) *FileInfo { return info } -func GenerateMiniPreviewImage(img image.Image) *[]byte { - preview := imaging.Resize(img, 16, 16, imaging.Lanczos) - - buf := new(bytes.Buffer) - - if err := jpeg.Encode(buf, preview, &jpeg.Options{Quality: 90}); err != nil { - mlog.Info("Unable to encode image as mini preview jpg", mlog.Err(err)) - return nil - } - data := buf.Bytes() - return &data -} - func GetInfoForBytes(name string, data io.ReadSeeker, size int) (*FileInfo, *AppError) { info := &FileInfo{ Name: name, diff --git a/services/slackimport/slackimport.go b/services/slackimport/slackimport.go index 13a5ff886f..34c9695d22 100644 --- a/services/slackimport/slackimport.go +++ b/services/slackimport/slackimport.go @@ -93,7 +93,7 @@ type Actions struct { GeneratePreviewImage func(image.Image, string) InvalidateAllCaches func() MaxPostSize func() int - PrepareImage func(fileData []byte) (image.Image, int, int) + PrepareImage func(fileData []byte) (image.Image, func(), error) } // SlackImporter is a service that allows to import slack dumps into mattermost @@ -772,11 +772,13 @@ func (si *SlackImporter) oldImportFile(timestamp time.Time, file io.Reader, team } if fileInfo.IsImage() && fileInfo.MimeType != "image/svg+xml" { - img, _, _ := si.actions.PrepareImage(data) - if img != nil { - si.actions.GenerateThumbnailImage(img, fileInfo.ThumbnailPath) - si.actions.GeneratePreviewImage(img, fileInfo.PreviewPath) + img, release, err := si.actions.PrepareImage(data) + if err != nil { + return nil, err } + defer release() + si.actions.GenerateThumbnailImage(img, fileInfo.ThumbnailPath) + si.actions.GeneratePreviewImage(img, fileInfo.PreviewPath) } return fileInfo, nil diff --git a/tests/fill_test_16bit_rgb.png b/tests/fill_test_16bit_rgb.png new file mode 100644 index 0000000000..fdff8521ee Binary files /dev/null and b/tests/fill_test_16bit_rgb.png differ diff --git a/tests/fill_test_16bit_rgb_out.png b/tests/fill_test_16bit_rgb_out.png new file mode 100644 index 0000000000..bb9741d53f Binary files /dev/null and b/tests/fill_test_16bit_rgb_out.png differ diff --git a/tests/fill_test_16bit_rgba.png b/tests/fill_test_16bit_rgba.png new file mode 100644 index 0000000000..7f7165a8ef Binary files /dev/null and b/tests/fill_test_16bit_rgba.png differ diff --git a/tests/fill_test_16bit_rgba_out.png b/tests/fill_test_16bit_rgba_out.png new file mode 100644 index 0000000000..491bee1ce5 Binary files /dev/null and b/tests/fill_test_16bit_rgba_out.png differ diff --git a/tests/fill_test_8bit_palette.png b/tests/fill_test_8bit_palette.png new file mode 100644 index 0000000000..1641fcf4ed Binary files /dev/null and b/tests/fill_test_8bit_palette.png differ diff --git a/tests/fill_test_8bit_palette_out.png b/tests/fill_test_8bit_palette_out.png new file mode 100644 index 0000000000..d865b78def Binary files /dev/null and b/tests/fill_test_8bit_palette_out.png differ diff --git a/tests/fill_test_8bit_rgb.png b/tests/fill_test_8bit_rgb.png new file mode 100644 index 0000000000..a3052ae3c2 Binary files /dev/null and b/tests/fill_test_8bit_rgb.png differ diff --git a/tests/fill_test_8bit_rgb_out.png b/tests/fill_test_8bit_rgb_out.png new file mode 100644 index 0000000000..499026d665 Binary files /dev/null and b/tests/fill_test_8bit_rgb_out.png differ diff --git a/tests/fill_test_8bit_rgba.png b/tests/fill_test_8bit_rgba.png new file mode 100644 index 0000000000..01b8a79a1e Binary files /dev/null and b/tests/fill_test_8bit_rgba.png differ diff --git a/tests/fill_test_8bit_rgba_out.png b/tests/fill_test_8bit_rgba_out.png new file mode 100644 index 0000000000..bd5c157f2a Binary files /dev/null and b/tests/fill_test_8bit_rgba_out.png differ diff --git a/tests/fill_test_opaque.png b/tests/fill_test_opaque.png new file mode 100644 index 0000000000..9e2e05491b Binary files /dev/null and b/tests/fill_test_opaque.png differ