[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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
23
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)
|
||||
}
|
||||
|
||||
267
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")
|
||||
|
||||
50
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)
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
28
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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
28
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)
|
||||
}
|
||||
|
||||
28
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."
|
||||
|
||||
@@ -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 |