[MM-29241] Image logic refactor (#17702)

* Image logic refactor

* Add missing translations

* Improve prepareImage

* Use iota

* Limit image encoder concurrency

* Unexport validation methods

* Avoid shortening on exported names

* Remove unnecessary complexity
Этот коммит содержится в:
Claudio Costa
2021-06-05 11:08:29 +02:00
коммит произвёл GitHub
родитель 6ffd31b6bd
Коммит 39c3b8ebf9
35 изменённых файлов: 1338 добавлений и 309 удалений

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

@@ -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)
}

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

@@ -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)
}

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

@@ -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")

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

@@ -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)
}

146
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
}
}

89
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()
})
}
}

225
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)
})
}

86
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
}

90
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)
})
}

76
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
}

66
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
}

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

@@ -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"`

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

@@ -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)
}

147
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)
}

103
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)
}
})
}
}

115
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)
})
}

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

@@ -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()))

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

@@ -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())

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

@@ -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)
}

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

@@ -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"

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

@@ -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)
}

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

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

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

@@ -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,

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

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

Двоичные данные
tests/fill_test_16bit_rgb.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.2 KiB

Двоичные данные
tests/fill_test_16bit_rgb_out.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.2 KiB

Двоичные данные
tests/fill_test_16bit_rgba.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.5 KiB

Двоичные данные
tests/fill_test_16bit_rgba_out.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.5 KiB

Двоичные данные
tests/fill_test_8bit_palette.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 724 B

Двоичные данные
tests/fill_test_8bit_palette_out.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 140 B

Двоичные данные
tests/fill_test_8bit_rgb.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 889 B

Двоичные данные
tests/fill_test_8bit_rgb_out.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 896 B

Двоичные данные
tests/fill_test_8bit_rgba.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.1 KiB

Двоичные данные
tests/fill_test_8bit_rgba_out.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 1.1 KiB

Двоичные данные
tests/fill_test_opaque.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 299 B