diff --git a/server/channels/api4/file_test.go b/server/channels/api4/file_test.go index f0e7d9a98c..cf88da84a6 100644 --- a/server/channels/api4/file_test.go +++ b/server/channels/api4/file_test.go @@ -60,7 +60,8 @@ func fileBytes(t *testing.T, path string) []byte { } func testDoUploadFileRequest(t testing.TB, c *model.Client4, url string, blob []byte, contentType string, - contentLength int64) (*model.FileUploadResponse, *model.Response, error) { + contentLength int64, +) (*model.FileUploadResponse, *model.Response, error) { req, err := http.NewRequest("POST", c.APIURL+"/files"+url, bytes.NewReader(blob)) require.NoError(t, err) @@ -390,6 +391,19 @@ func TestUploadFiles(t *testing.T) { expectedImageMiniPreview: []bool{true}, expectedCreatorId: th.BasicUser.Id, }, + // 5MB+ JPEG + { + title: "Happy image thumbnail/preview 5MB+", + names: []string{"orientation_test_9.jpeg"}, + expectedImageThumbnailNames: []string{"orientation_test_9_expected_thumb.jpeg"}, + expectedImagePreviewNames: []string{"orientation_test_9_expected_preview.jpeg"}, + expectImage: true, + expectedImageWidths: []int{4000}, + expectedImageHeights: []int{2667}, + expectedImageHasPreview: []bool{true}, + expectedImageMiniPreview: []bool{true}, + expectedCreatorId: th.BasicUser.Id, + }, // TIFF preview test { title: "Happy image thumbnail/preview 9", diff --git a/server/channels/api4/team.go b/server/channels/api4/team.go index 4b8b28908b..d9593fcc44 100644 --- a/server/channels/api4/team.go +++ b/server/channels/api4/team.go @@ -1664,7 +1664,6 @@ func getTeamIcon(c *Context, w http.ResponseWriter, r *http.Request) { } team, err := c.App.GetTeam(c.Params.TeamId) - if err != nil { c.Err = err return @@ -1742,7 +1741,7 @@ func setTeamIcon(c *Context, w http.ResponseWriter, r *http.Request) { imageData := imageArray[0] - if err := c.App.SetTeamIcon(c.Params.TeamId, imageData); err != nil { + if err := c.App.SetTeamIcon(c.AppContext, c.Params.TeamId, imageData); err != nil { c.Err = err return } diff --git a/server/channels/app/file.go b/server/channels/app/file.go index 25f56e3ba1..94bece11bd 100644 --- a/server/channels/app/file.go +++ b/server/channels/app/file.go @@ -770,11 +770,8 @@ func (t *UploadFileTask) init(a *App) { // upload, returning a rejection error. In this case FileInfo would have // contained the last "good" FileInfo before the execution of that plugin. func (a *App) UploadFileX(c request.CTX, channelID, name string, input io.Reader, - opts ...func(*UploadFileTask)) (*model.FileInfo, *model.AppError) { - c = c.WithLogger(c.Logger().With( - mlog.String("file_name", name), - )) - + opts ...func(*UploadFileTask), +) (*model.FileInfo, *model.AppError) { t := &UploadFileTask{ Logger: c.Logger(), ChannelId: filepath.Base(channelID), @@ -790,6 +787,12 @@ func (a *App) UploadFileX(c request.CTX, channelID, name string, input io.Reader o(t) } + c = c.WithLogger(c.Logger().With( + mlog.String("file_name", name), + mlog.String("channel_id", channelID), + mlog.String("user_id", t.UserId), + )) + if *a.Config().FileSettings.DriverName == "" { return nil, t.newAppError("api.file.upload_file.storage.app_error", http.StatusNotImplemented) } @@ -880,14 +883,14 @@ func (t *UploadFileTask) preprocessImage() *model.AppError { } // If we fail to decode, return "as is". - w, h, err := imaging.GetDimensions(t.teeInput) + cfg, format, err := t.imgDecoder.DecodeConfig(t.teeInput) if err != nil { return nil } - t.fileinfo.Width = w - t.fileinfo.Height = h + t.fileinfo.Width = cfg.Width + t.fileinfo.Height = cfg.Height - if err = checkImageResolutionLimit(w, h, t.maxImageRes); err != nil { + if err = checkImageResolutionLimit(cfg.Width, cfg.Height, t.maxImageRes); err != nil { return t.newAppError("api.file.upload_file.large_image_detailed.app_error", http.StatusBadRequest) } @@ -899,12 +902,14 @@ 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 = imaging.GetImageOrientation(io.MultiReader(bytes.NewReader(t.buf.Bytes()), t.teeInput)); err == nil && + if t.imageOrientation, err = imaging.GetImageOrientation(io.MultiReader(bytes.NewReader(t.buf.Bytes()), t.teeInput), format); 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 + } else if err != nil { + t.Logger.Warn("Failed to get image orientation", mlog.Err(err)) } // For animated GIFs disable the preview; since we have to Decode gifs @@ -1045,12 +1050,14 @@ func (a *App) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTe return nil, data, err } - if orientation, err := imaging.GetImageOrientation(bytes.NewReader(data)); err == nil && + if orientation, err := imaging.GetImageOrientation(bytes.NewReader(data), info.MimeType); err == nil && (orientation == imaging.RotatedCWMirrored || orientation == imaging.RotatedCCW || orientation == imaging.RotatedCCWMirrored || orientation == imaging.RotatedCW) { info.Width, info.Height = info.Height, info.Width + } else if err != nil { + c.Logger().Warn("Failed to get image orientation", mlog.Err(err)) } info.Id = model.NewId() @@ -1161,7 +1168,7 @@ func prepareImage(rctx request.CTX, imgDecoder *imaging.Decoder, imgData io.Read imgData.Seek(0, io.SeekStart) // Flip the image to be upright - orientation, err := imaging.GetImageOrientation(imgData) + orientation, err := imaging.GetImageOrientation(imgData, imgType) if err != nil { rctx.Logger().Debug("GetImageOrientation failed", mlog.Err(err)) } diff --git a/server/channels/app/imaging/orientation.go b/server/channels/app/imaging/orientation.go index 267f80bcce..98fcffac9d 100644 --- a/server/channels/app/imaging/orientation.go +++ b/server/channels/app/imaging/orientation.go @@ -4,12 +4,14 @@ package imaging import ( + "errors" "fmt" "image" "io" + "strings" "github.com/anthonynsimon/bild/transform" - "github.com/rwcarlsen/goexif/exif" + "github.com/bep/imagemeta" ) const ( @@ -33,6 +35,8 @@ const ( RotatedCW ) +var errStopDecoding = fmt.Errorf("stop decoding") + // MakeImageUpright changes the orientation of the given image. func MakeImageUpright(img image.Image, orientation int) image.Image { switch orientation { @@ -55,23 +59,101 @@ func MakeImageUpright(img image.Image, orientation int) image.Image { } } +type fwSeeker struct { + r io.Reader + pos int64 +} + +func (f *fwSeeker) Read(p []byte) (int, error) { + n, err := f.r.Read(p) + if err != nil { + return n, err + } + f.pos += int64(n) + return n, nil +} + +func (f *fwSeeker) Seek(offset int64, whence int) (int64, error) { + isForwardSeek := (whence == io.SeekStart && offset >= f.pos) || + (whence == io.SeekCurrent && offset >= 0) + + // We only support seeking forward. + if !isForwardSeek { + return 0, fmt.Errorf("seeking backwards is not supported") + } + + toRead := offset + if whence == io.SeekStart { + toRead -= f.pos + } + + // Seeking forward means we can simply discard the data. + n, err := io.CopyN(io.Discard, f.r, toRead) + if err != nil { + return n, fmt.Errorf("failed to seek: %w", err) + } + + f.pos += n + + return f.pos, nil +} + // 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 { +// image orientation. Supported formats are JPEG, PNG, TIFF, and WebP. +// Passing an io.ReadSeeker is preferable as we can't guarantee a plain +// io.Reader will work for all formats (e.g. TIFF requires backwards seeking). +func GetImageOrientation(input io.Reader, format string) (int, error) { + orientation := Upright + + // Strip the "image/" prefix from the format in case it's a MIME type. + format, _ = strings.CutPrefix(format, "image/") + + var imgFormat imagemeta.ImageFormat + switch format { + case "jpeg": + imgFormat = imagemeta.JPEG + case "png": + imgFormat = imagemeta.PNG + case "tiff": + imgFormat = imagemeta.TIFF + case "webp": + imgFormat = imagemeta.WebP + default: + // We don't support EXIF on any other format. + return orientation, fmt.Errorf("unsupported image format: %s", format) + } + + var rs io.ReadSeeker + if r, ok := input.(io.ReadSeeker); ok { + rs = r + } else { + rs = &fwSeeker{r: input} + } + + opts := imagemeta.Options{ + R: rs, + HandleTag: func(tag imagemeta.TagInfo) error { + if tag.Tag == "Orientation" { + if o, ok := tag.Value.(uint16); ok { + orientation = int(o) + // Stop decoding after we've found the orientation tag] + // since it's the only one we care about. + return errStopDecoding + } + } + return nil + }, + ShouldHandleTag: func(tag imagemeta.TagInfo) bool { + // We only care about the orientation tag. + return tag.Tag == "Orientation" + }, + Sources: imagemeta.EXIF, // We only care about EXIF data. + ImageFormat: imgFormat, + } + + if err := imagemeta.Decode(opts); err != nil && !errors.Is(err, errStopDecoding) { return Upright, fmt.Errorf("failed to decode exif data: %w", err) } - tag, err := exifData.Get("Orientation") - if err != nil { - return Upright, fmt.Errorf("failed to get orientation field from exif data: %w", err) - } - - orientation, err := tag.Int(0) - if err != nil { - return Upright, fmt.Errorf("failed to get value from exif tag: %w", err) - } - return orientation, nil } diff --git a/server/channels/app/imaging/orientation_test.go b/server/channels/app/imaging/orientation_test.go new file mode 100644 index 0000000000..862343c659 --- /dev/null +++ b/server/channels/app/imaging/orientation_test.go @@ -0,0 +1,199 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package imaging + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/mattermost/mattermost/server/v8/channels/utils/fileutils" + "github.com/stretchr/testify/require" +) + +func TestFwSeeker(t *testing.T) { + t.Run("Read", func(t *testing.T) { + data := []byte("hello world") + reader := bytes.NewReader(data) + seeker := &fwSeeker{r: reader} + + // Read the first 5 bytes + buf := make([]byte, 5) + n, err := seeker.Read(buf) + require.NoError(t, err) + require.Equal(t, 5, n) + require.Equal(t, []byte("hello"), buf) + + // Read the next 6 bytes + buf = make([]byte, 6) + n, err = seeker.Read(buf) + require.NoError(t, err) + require.Equal(t, 6, n) + require.Equal(t, []byte(" world"), buf) + + // Try to read more, should get EOF + buf = make([]byte, 1) + n, err = seeker.Read(buf) + require.Equal(t, 0, n) + require.Equal(t, io.EOF, err) + }) + + t.Run("Seek forward from start", func(t *testing.T) { + data := []byte("hello world") + reader := bytes.NewReader(data) + seeker := &fwSeeker{r: reader} + + // Seek forward 6 bytes + pos, err := seeker.Seek(6, io.SeekStart) + require.NoError(t, err) + require.Equal(t, int64(6), pos) + + // Seeking from the same position should work + pos, err = seeker.Seek(6, io.SeekStart) + require.NoError(t, err) + require.Equal(t, int64(6), pos) + + // Seeking again from start should work. + pos, err = seeker.Seek(7, io.SeekStart) + require.NoError(t, err) + require.Equal(t, int64(7), pos) + + // Seeking backwards should not be supported + _, err = seeker.Seek(6, io.SeekStart) + require.EqualError(t, err, "seeking backwards is not supported") + + // Read the remaining data + buf := make([]byte, 4) + n, err := seeker.Read(buf) + require.NoError(t, err) + require.Equal(t, 4, n) + require.Equal(t, []byte("orld"), buf) + }) + + t.Run("Seek forward from current", func(t *testing.T) { + data := []byte("hello world") + reader := bytes.NewReader(data) + seeker := &fwSeeker{r: reader} + + // Read first 6 bytes + buf := make([]byte, 6) + n, err := seeker.Read(buf) + require.NoError(t, err) + require.Equal(t, 6, n) + require.Equal(t, []byte("hello "), buf) + + // Seek forward 2 more bytes from current position + pos, err := seeker.Seek(2, io.SeekCurrent) + require.NoError(t, err) + require.Equal(t, int64(8), pos) + + // Read the remaining data + buf = make([]byte, 3) + n, err = seeker.Read(buf) + require.NoError(t, err) + require.Equal(t, 3, n) + require.Equal(t, []byte("rld"), buf) + }) + + t.Run("Seek backward not supported", func(t *testing.T) { + data := []byte("hello world") + reader := bytes.NewReader(data) + seeker := &fwSeeker{r: reader} + + // Try to seek backward + _, err := seeker.Seek(-1, io.SeekCurrent) + require.EqualError(t, err, "seeking backwards is not supported") + + // Try to seek from end + _, err = seeker.Seek(0, io.SeekEnd) + require.EqualError(t, err, "seeking backwards is not supported") + }) + + t.Run("Seek beyond EOF", func(t *testing.T) { + data := []byte("hello") + reader := bytes.NewReader(data) + seeker := &fwSeeker{r: reader} + + // Try to seek beyond EOF + n, err := seeker.Seek(10, io.SeekStart) + require.EqualError(t, err, "failed to seek: EOF") + require.Equal(t, int64(5), n) // Should have read until EOF (5 bytes) + }) +} + +func TestGetImageOrientation(t *testing.T) { + imgDir, ok := fileutils.FindDir("tests/exif_samples") + require.True(t, ok, "Failed to find exif samples directory") + + // Define orientations and their corresponding file prefixes + orientations := map[string]int{ + "up": Upright, + "up-mirrored": UprightMirrored, + "down": UpsideDown, + "down-mirrored": UpsideDownMirrored, + "left": RotatedCCW, + "left-mirrored": RotatedCWMirrored, + "right": RotatedCW, + "right-mirrored": RotatedCCWMirrored, + } + + // Define supported formats + formats := []string{"jpg", "png", "tiff", "webp"} + + // Generate test cases for all combinations + var testCases []struct { + name string + fileName string + expectedOrientation int + } + + for prefix, orientation := range orientations { + for _, format := range formats { + testCases = append(testCases, struct { + name string + fileName string + expectedOrientation int + }{ + name: fmt.Sprintf("%s (%s)", prefix, format), + fileName: fmt.Sprintf("%s.%s", prefix, format), + expectedOrientation: orientation, + }) + } + } + + dec, err := NewDecoder(DecoderOptions{}) + require.NoError(t, err) + + for _, tc := range testCases { + var orientation int + imgPath := filepath.Join(imgDir, tc.fileName) + file, err := os.Open(imgPath) + require.NoError(t, err) + defer file.Close() + + _, format, err := dec.DecodeConfig(file) + require.NoError(t, err) + + t.Run(tc.name+"_file", func(t *testing.T) { + _, err = file.Seek(0, io.SeekStart) + require.NoError(t, err) + + orientation, err = GetImageOrientation(file, format) + require.NoError(t, err) + require.Equal(t, tc.expectedOrientation, orientation, "Incorrect orientation detected for %s", tc.fileName) + }) + + t.Run(tc.name+"_reader", func(t *testing.T) { + _, err = file.Seek(0, io.SeekStart) + require.NoError(t, err) + + orientation, err = GetImageOrientation(&io.LimitedReader{R: file, N: 1024 * 1024}, format) + require.NoError(t, err) + require.Equal(t, tc.expectedOrientation, orientation, "Incorrect orientation detected for %s", tc.fileName) + }) + } +} diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index 7d903bd446..c07f897669 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -948,7 +948,7 @@ func (api *PluginAPI) SetTeamIcon(teamID string, data []byte) *model.AppError { return err } - return api.app.SetTeamIconFromFile(team, bytes.NewReader(data)) + return api.app.SetTeamIconFromFile(api.ctx, team, bytes.NewReader(data)) } func (api *PluginAPI) OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError { @@ -1103,12 +1103,15 @@ func (api *PluginAPI) UpdateUserRoles(userID string, newRoles string) (*model.Us func (api *PluginAPI) LogDebug(msg string, keyValuePairs ...any) { api.logger.Debugw(msg, keyValuePairs...) } + func (api *PluginAPI) LogInfo(msg string, keyValuePairs ...any) { api.logger.Infow(msg, keyValuePairs...) } + func (api *PluginAPI) LogError(msg string, keyValuePairs ...any) { api.logger.Errorw(msg, keyValuePairs...) } + func (api *PluginAPI) LogWarn(msg string, keyValuePairs ...any) { api.logger.Warnw(msg, keyValuePairs...) } @@ -1320,7 +1323,8 @@ func (api *PluginAPI) DeleteOAuthApp(appID string) *model.AppError { // PublishPluginClusterEvent broadcasts a plugin event to all other running instances of // the calling plugin. func (api *PluginAPI) PublishPluginClusterEvent(ev model.PluginClusterEvent, - opts model.PluginClusterEventSendOptions) error { + opts model.PluginClusterEventSendOptions, +) error { if api.app.Cluster() == nil { return nil } diff --git a/server/channels/app/plugin_api_test.go b/server/channels/app/plugin_api_test.go index 9a543b4f45..4cde27ce87 100644 --- a/server/channels/app/plugin_api_test.go +++ b/server/channels/app/plugin_api_test.go @@ -1177,7 +1177,7 @@ func TestPluginAPIGetTeamIcon(t *testing.T) { fileReader := bytes.NewReader(dataBytes) // Set the Team Icon - appErr := th.App.SetTeamIconFromFile(th.BasicTeam, fileReader) + appErr := th.App.SetTeamIconFromFile(th.Context, th.BasicTeam, fileReader) require.Nil(t, appErr) // Get the team icon to check @@ -1239,7 +1239,7 @@ func TestPluginAPIRemoveTeamIcon(t *testing.T) { fileReader := bytes.NewReader(dataBytes) // Set the Team Icon - err := th.App.SetTeamIconFromFile(th.BasicTeam, fileReader) + err := th.App.SetTeamIconFromFile(th.Context, th.BasicTeam, fileReader) require.Nil(t, err) err = api.RemoveTeamIcon(th.BasicTeam.Id) require.Nil(t, err) diff --git a/server/channels/app/post_metadata.go b/server/channels/app/post_metadata.go index 1079ec1bbd..3a5decbccd 100644 --- a/server/channels/app/post_metadata.go +++ b/server/channels/app/post_metadata.go @@ -650,7 +650,6 @@ func (a *App) getLinkMetadata(c request.CTX, requestURL string, timestamp int64, var err error if looksLikeAPermalink(requestURL, a.GetSiteURL()) && *a.Config().ServiceSettings.EnablePermalinkPreviews { permalink, err = a.getLinkMetadataForPermalink(c, requestURL) - if err != nil { return nil, nil, nil, err } @@ -784,7 +783,7 @@ func (a *App) getLinkMetadataForURL(c request.CTX, requestURL string) (*opengrap if err == nil { // Parse the data - og, image, err = a.parseLinkMetadata(requestURL, body, contentType) + og, image, err = a.parseLinkMetadata(c, requestURL, body, contentType) } og = model.TruncateOpenGraph(og) // remove unwanted length of texts @@ -878,7 +877,7 @@ func peekContentType(p *bufio.Reader) string { return http.DetectContentType(byt) } -func (a *App) parseLinkMetadata(requestURL string, body io.Reader, contentType string) (*opengraph.OpenGraph, *model.PostImage, error) { +func (a *App) parseLinkMetadata(rctx request.CTX, requestURL string, body io.Reader, contentType string) (*opengraph.OpenGraph, *model.PostImage, error) { if contentType == "" { bufRd := bufio.NewReader(body) // If the content-type is missing we try to detect it from the actual data. @@ -893,7 +892,7 @@ func (a *App) parseLinkMetadata(requestURL string, body io.Reader, contentType s return nil, image, nil } else if strings.HasPrefix(contentType, "image") { - image, err := parseImages(io.LimitReader(body, MaxMetadataImageSize)) + image, err := parseImages(rctx, requestURL, io.LimitReader(body, MaxMetadataImageSize)) return nil, image, err } else if strings.HasPrefix(contentType, "text/html") { og := a.parseOpenGraphMetadata(requestURL, body, contentType) @@ -909,7 +908,7 @@ func (a *App) parseLinkMetadata(requestURL string, body io.Reader, contentType s return nil, nil, nil } -func parseImages(body io.Reader) (*model.PostImage, error) { +func parseImages(rctx request.CTX, requestURL string, body io.Reader) (*model.PostImage, error) { // Store any data that is read for the config for any further processing buf := &bytes.Buffer{} t := io.TeeReader(body, buf) @@ -927,12 +926,14 @@ func parseImages(body io.Reader) (*model.PostImage, error) { } if format == "jpeg" { - if imageOrientation, err := imaging.GetImageOrientation(io.MultiReader(buf, body)); err == nil && + if imageOrientation, err := imaging.GetImageOrientation(io.MultiReader(buf, body), format); err == nil && (imageOrientation == imaging.RotatedCWMirrored || imageOrientation == imaging.RotatedCCW || imageOrientation == imaging.RotatedCCWMirrored || imageOrientation == imaging.RotatedCW) { image.Width, image.Height = image.Height, image.Width + } else if err != nil { + rctx.Logger().Warn("Failed to get image orientation", mlog.Err(err), mlog.String("request_url", requestURL)) } } diff --git a/server/channels/app/post_metadata_test.go b/server/channels/app/post_metadata_test.go index 081bd24280..da86ee7ca5 100644 --- a/server/channels/app/post_metadata_test.go +++ b/server/channels/app/post_metadata_test.go @@ -29,6 +29,8 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/httpservice" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/app/platform" "github.com/mattermost/mattermost/server/v8/channels/utils/testutils" "github.com/mattermost/mattermost/server/v8/platform/services/imageproxy" @@ -2673,7 +2675,7 @@ func TestParseLinkMetadata(t *testing.T) { } t.Run("image", func(t *testing.T) { - og, dimensions, err := th.App.parseLinkMetadata(imageURL, makeImageReader(), "image/png") + og, dimensions, err := th.App.parseLinkMetadata(th.Context, imageURL, makeImageReader(), "image/png") assert.NoError(t, err) assert.Nil(t, og) @@ -2685,7 +2687,7 @@ func TestParseLinkMetadata(t *testing.T) { }) t.Run("image with no content-type given", func(t *testing.T) { - og, dimensions, err := th.App.parseLinkMetadata(imageURL, makeImageReader(), "") + og, dimensions, err := th.App.parseLinkMetadata(th.Context, imageURL, makeImageReader(), "") assert.NoError(t, err) assert.Nil(t, og) @@ -2697,7 +2699,7 @@ func TestParseLinkMetadata(t *testing.T) { }) t.Run("malformed image", func(t *testing.T) { - og, dimensions, err := th.App.parseLinkMetadata(imageURL, makeOpenGraphReader(), "image/png") + og, dimensions, err := th.App.parseLinkMetadata(th.Context, imageURL, makeOpenGraphReader(), "image/png") assert.Error(t, err) assert.Nil(t, og) @@ -2705,7 +2707,7 @@ func TestParseLinkMetadata(t *testing.T) { }) t.Run("opengraph", func(t *testing.T) { - og, dimensions, err := th.App.parseLinkMetadata(ogURL, makeOpenGraphReader(), "text/html; charset=utf-8") + og, dimensions, err := th.App.parseLinkMetadata(th.Context, ogURL, makeOpenGraphReader(), "text/html; charset=utf-8") assert.NoError(t, err) assert.NotNil(t, og) @@ -2716,7 +2718,7 @@ func TestParseLinkMetadata(t *testing.T) { }) t.Run("malformed opengraph", func(t *testing.T) { - og, dimensions, err := th.App.parseLinkMetadata(ogURL, makeImageReader(), "text/html; charset=utf-8") + og, dimensions, err := th.App.parseLinkMetadata(th.Context, ogURL, makeImageReader(), "text/html; charset=utf-8") assert.NoError(t, err) assert.Nil(t, og) @@ -2724,7 +2726,7 @@ func TestParseLinkMetadata(t *testing.T) { }) t.Run("neither", func(t *testing.T) { - og, dimensions, err := th.App.parseLinkMetadata("http://example.com/test.wad", strings.NewReader("garbage"), "application/x-doom") + og, dimensions, err := th.App.parseLinkMetadata(th.Context, "http://example.com/test.wad", strings.NewReader("garbage"), "application/x-doom") assert.NoError(t, err) assert.Nil(t, og) @@ -2732,7 +2734,7 @@ func TestParseLinkMetadata(t *testing.T) { }) t.Run("svg", func(t *testing.T) { - og, dimensions, err := th.App.parseLinkMetadata("http://example.com/image.svg", nil, "image/svg+xml") + og, dimensions, err := th.App.parseLinkMetadata(th.Context, "http://example.com/image.svg", nil, "image/svg+xml") assert.NoError(t, err) assert.Nil(t, og) @@ -2820,6 +2822,14 @@ func TestParseImages(t *testing.T) { Format: "jpeg", }, }, + "jpg-9": { + FileName: "orientation_test_9.jpeg", + Expected: &model.PostImage{ + Width: 4000, + Height: 2667, + Format: "jpeg", + }, + }, "animated gif": { FileName: "testgif.gif", Expected: &model.PostImage{ @@ -2842,7 +2852,7 @@ func TestParseImages(t *testing.T) { file, err := testutils.ReadTestFile(testCase.FileName) require.NoError(t, err) - result, err := parseImages(bytes.NewReader(file)) + result, err := parseImages(request.EmptyContext(mlog.CreateConsoleTestLogger(t)), "", bytes.NewReader(file)) if testCase.ExpectError { assert.Error(t, err) } else { diff --git a/server/channels/app/team.go b/server/channels/app/team.go index ebaa2b5bce..17f90f9dcb 100644 --- a/server/channels/app/team.go +++ b/server/channels/app/team.go @@ -1944,16 +1944,16 @@ func (a *App) GetTeamIcon(team *model.Team) ([]byte, *model.AppError) { return data, nil } -func (a *App) SetTeamIcon(teamID string, imageData *multipart.FileHeader) *model.AppError { +func (a *App) SetTeamIcon(rctx request.CTX, teamID string, imageData *multipart.FileHeader) *model.AppError { file, err := imageData.Open() if err != nil { return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.open.app_error", nil, "", http.StatusBadRequest).Wrap(err) } defer file.Close() - return a.SetTeamIconFromMultiPartFile(teamID, file) + return a.SetTeamIconFromMultiPartFile(rctx, teamID, file) } -func (a *App) SetTeamIconFromMultiPartFile(teamID string, file multipart.File) *model.AppError { +func (a *App) SetTeamIconFromMultiPartFile(rctx request.CTX, teamID string, file multipart.File) *model.AppError { team, getTeamErr := a.GetTeam(teamID) if getTeamErr != nil { @@ -1969,17 +1969,21 @@ func (a *App) SetTeamIconFromMultiPartFile(teamID string, file multipart.File) * nil, "", http.StatusBadRequest).Wrap(limitErr) } - return a.SetTeamIconFromFile(team, file) + return a.SetTeamIconFromFile(rctx, team, file) } -func (a *App) SetTeamIconFromFile(team *model.Team, file io.Reader) *model.AppError { +func (a *App) SetTeamIconFromFile(rctx request.CTX, team *model.Team, file io.ReadSeeker) *model.AppError { // Decode image into Image object - img, _, err := image.Decode(file) + img, format, err := image.Decode(file) if err != nil { return model.NewAppError("SetTeamIcon", "api.team.set_team_icon.decode.app_error", nil, "", http.StatusBadRequest).Wrap(err) } - orientation, _ := imaging.GetImageOrientation(file) + orientation, err := imaging.GetImageOrientation(file, format) + if err != nil { + rctx.Logger().Warn("Failed to get image orientation", mlog.Err(err)) + } + img = imaging.MakeImageUpright(img, orientation) // Scale team icon diff --git a/server/channels/app/user.go b/server/channels/app/user.go index e20d5fb420..02f1cff093 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -882,14 +882,18 @@ func (a *App) SetProfileImageFromMultiPartFile(c request.CTX, userID string, fil return a.SetProfileImageFromFile(c, userID, file) } -func (a *App) AdjustImage(file io.Reader) (*bytes.Buffer, *model.AppError) { +func (a *App) AdjustImage(rctx request.CTX, file io.ReadSeeker) (*bytes.Buffer, *model.AppError) { // Decode image into Image object - img, _, err := a.ch.imgDecoder.Decode(file) + img, format, err := a.ch.imgDecoder.Decode(file) if err != nil { return nil, model.NewAppError("SetProfileImage", "api.user.upload_profile_user.decode.app_error", nil, "", http.StatusBadRequest).Wrap(err) } - orientation, _ := imaging.GetImageOrientation(file) + orientation, err := imaging.GetImageOrientation(file, format) + if err != nil { + rctx.Logger().Warn("Failed to get image orientation", mlog.Err(err)) + } + img = imaging.MakeImageUpright(img, orientation) // Scale profile image @@ -904,8 +908,8 @@ func (a *App) AdjustImage(file io.Reader) (*bytes.Buffer, *model.AppError) { return buf, nil } -func (a *App) SetProfileImageFromFile(c request.CTX, userID string, file io.Reader) *model.AppError { - buf, err := a.AdjustImage(file) +func (a *App) SetProfileImageFromFile(c request.CTX, userID string, file io.ReadSeeker) *model.AppError { + buf, err := a.AdjustImage(c, file) if err != nil { return err } @@ -1363,7 +1367,6 @@ func (a *App) UpdateUser(c request.CTX, user *model.User, sendNotifications bool func (a *App) UpdateUserActive(c request.CTX, userID string, active bool) *model.AppError { user, err := a.GetUser(userID) - if err != nil { return err } @@ -1671,7 +1674,6 @@ func (a *App) GetPasswordRecoveryToken(token string) (*model.Token, *model.AppEr func (a *App) GetTokenById(token string) (*model.Token, *model.AppError) { rtoken, err := a.Srv().Store().Token().GetByToken(token) - if err != nil { var status int @@ -2043,7 +2045,6 @@ func (a *App) VerifyUserEmail(userID, email string) *model.AppError { a.InvalidateCacheForUser(userID) user, err := a.GetUser(userID) - if err != nil { return err } @@ -2788,7 +2789,6 @@ func (a *App) UpdateThreadFollowForUserFromChannelAdd(c request.CTX, userID, tea message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, teamID, "", userID, nil, "") userThread, err := a.Srv().Store().Thread().GetThreadForUser(tm, true, a.IsPostPriorityEnabled()) - if err != nil { var errNotFound *store.ErrNotFound if errors.As(err, &errNotFound) { diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go index c0b668ff4d..15a93de5bd 100644 --- a/server/channels/app/user_test.go +++ b/server/channels/app/user_test.go @@ -112,14 +112,14 @@ func TestAdjustProfileImage(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() - _, appErr := th.App.AdjustImage(bytes.NewReader([]byte{})) + _, appErr := th.App.AdjustImage(th.Context, bytes.NewReader([]byte{})) require.NotNil(t, appErr) // test image isn't the correct dimensions // it should be adjusted testjpg, err := testutils.ReadTestFile("testjpg.jpg") require.NoError(t, err) - adjusted, appErr := th.App.AdjustImage(bytes.NewReader(testjpg)) + adjusted, appErr := th.App.AdjustImage(th.Context, bytes.NewReader(testjpg)) require.Nil(t, appErr) assert.True(t, adjusted.Len() > 0) assert.NotEqual(t, testjpg, adjusted) @@ -128,7 +128,7 @@ func TestAdjustProfileImage(t *testing.T) { user := th.BasicUser image, appErr := th.App.GetDefaultProfileImage(user) require.Nil(t, appErr) - image2, appErr := th.App.AdjustImage(bytes.NewReader(image)) + image2, appErr := th.App.AdjustImage(th.Context, bytes.NewReader(image)) require.Nil(t, appErr) assert.Equal(t, image, image2.Bytes()) } @@ -317,7 +317,8 @@ func TestCreateUser(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() user := &model.User{ @@ -1924,7 +1925,7 @@ func TestUpdateUserRolesWithUser(t *testing.T) { _, err = th.App.UpdateUserRolesWithUser(th.Context, user, "does not exist", false) require.NotNil(t, err) - //Test reset to User role + // Test reset to User role user, err = th.App.UpdateUserRolesWithUser(th.Context, user, model.SystemUserRoleId, false) require.Nil(t, err) assert.Equal(t, user.Roles, model.SystemUserRoleId) diff --git a/server/go.mod b/server/go.mod index 1227745700..a522f088ac 100644 --- a/server/go.mod +++ b/server/go.mod @@ -10,6 +10,7 @@ require ( github.com/anthonynsimon/bild v0.14.0 github.com/avct/uasurfer v0.0.0-20240501094946-ca0c4d1e541b github.com/aws/aws-sdk-go v1.55.6 + github.com/bep/imagemeta v0.10.0 github.com/blang/semver/v4 v4.0.0 github.com/blevesearch/bleve/v2 v2.4.4-0.20250115090822-cbafdca08538 github.com/cespare/xxhash/v2 v2.3.0 @@ -59,7 +60,6 @@ require ( github.com/reflog/dateconstraints v0.2.1 github.com/rs/cors v1.11.1 github.com/rudderlabs/analytics-go v3.3.3+incompatible - github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 @@ -75,7 +75,7 @@ require ( golang.org/x/crypto v0.32.0 golang.org/x/image v0.23.0 golang.org/x/net v0.34.0 - golang.org/x/sync v0.10.0 + golang.org/x/sync v0.12.0 golang.org/x/term v0.28.0 gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v3 v3.0.1 @@ -222,7 +222,7 @@ require ( golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/tools v0.29.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect google.golang.org/grpc v1.70.0 // indirect diff --git a/server/go.sum b/server/go.sum index c48c6533b6..933697bc2f 100644 --- a/server/go.sum +++ b/server/go.sum @@ -56,6 +56,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bep/imagemeta v0.10.0 h1:kbQxe8SLHTUEEOT/DvLMGslIJy9Z1R+ZAckkIUnUBw4= +github.com/bep/imagemeta v0.10.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8= github.com/bits-and-blooms/bitset v1.10.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= @@ -758,8 +760,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -821,8 +824,9 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= diff --git a/server/tests/exif_samples/down-mirrored.jpg b/server/tests/exif_samples/down-mirrored.jpg new file mode 100644 index 0000000000..34a7b1d395 Binary files /dev/null and b/server/tests/exif_samples/down-mirrored.jpg differ diff --git a/server/tests/exif_samples/down-mirrored.png b/server/tests/exif_samples/down-mirrored.png new file mode 100644 index 0000000000..9d8a391766 Binary files /dev/null and b/server/tests/exif_samples/down-mirrored.png differ diff --git a/server/tests/exif_samples/down-mirrored.tiff b/server/tests/exif_samples/down-mirrored.tiff new file mode 100644 index 0000000000..5fdfcd7a19 Binary files /dev/null and b/server/tests/exif_samples/down-mirrored.tiff differ diff --git a/server/tests/exif_samples/down-mirrored.webp b/server/tests/exif_samples/down-mirrored.webp new file mode 100644 index 0000000000..30d49a249e Binary files /dev/null and b/server/tests/exif_samples/down-mirrored.webp differ diff --git a/server/tests/exif_samples/down.jpg b/server/tests/exif_samples/down.jpg new file mode 100644 index 0000000000..9077a7c92b Binary files /dev/null and b/server/tests/exif_samples/down.jpg differ diff --git a/server/tests/exif_samples/down.png b/server/tests/exif_samples/down.png new file mode 100644 index 0000000000..0b4df66f48 Binary files /dev/null and b/server/tests/exif_samples/down.png differ diff --git a/server/tests/exif_samples/down.tiff b/server/tests/exif_samples/down.tiff new file mode 100644 index 0000000000..a84e041c98 Binary files /dev/null and b/server/tests/exif_samples/down.tiff differ diff --git a/server/tests/exif_samples/down.webp b/server/tests/exif_samples/down.webp new file mode 100644 index 0000000000..0025aa9ad6 Binary files /dev/null and b/server/tests/exif_samples/down.webp differ diff --git a/server/tests/exif_samples/left-mirrored.jpg b/server/tests/exif_samples/left-mirrored.jpg new file mode 100644 index 0000000000..1832702492 Binary files /dev/null and b/server/tests/exif_samples/left-mirrored.jpg differ diff --git a/server/tests/exif_samples/left-mirrored.png b/server/tests/exif_samples/left-mirrored.png new file mode 100644 index 0000000000..958ba70425 Binary files /dev/null and b/server/tests/exif_samples/left-mirrored.png differ diff --git a/server/tests/exif_samples/left-mirrored.tiff b/server/tests/exif_samples/left-mirrored.tiff new file mode 100644 index 0000000000..97881f24c1 Binary files /dev/null and b/server/tests/exif_samples/left-mirrored.tiff differ diff --git a/server/tests/exif_samples/left-mirrored.webp b/server/tests/exif_samples/left-mirrored.webp new file mode 100644 index 0000000000..5a4e2eeb23 Binary files /dev/null and b/server/tests/exif_samples/left-mirrored.webp differ diff --git a/server/tests/exif_samples/left.jpg b/server/tests/exif_samples/left.jpg new file mode 100644 index 0000000000..ad1f89850f Binary files /dev/null and b/server/tests/exif_samples/left.jpg differ diff --git a/server/tests/exif_samples/left.png b/server/tests/exif_samples/left.png new file mode 100644 index 0000000000..63be08b029 Binary files /dev/null and b/server/tests/exif_samples/left.png differ diff --git a/server/tests/exif_samples/left.tiff b/server/tests/exif_samples/left.tiff new file mode 100644 index 0000000000..d9b91705c1 Binary files /dev/null and b/server/tests/exif_samples/left.tiff differ diff --git a/server/tests/exif_samples/left.webp b/server/tests/exif_samples/left.webp new file mode 100644 index 0000000000..fc93c3279b Binary files /dev/null and b/server/tests/exif_samples/left.webp differ diff --git a/server/tests/exif_samples/right-mirrored.jpg b/server/tests/exif_samples/right-mirrored.jpg new file mode 100644 index 0000000000..cc8a29aebe Binary files /dev/null and b/server/tests/exif_samples/right-mirrored.jpg differ diff --git a/server/tests/exif_samples/right-mirrored.png b/server/tests/exif_samples/right-mirrored.png new file mode 100644 index 0000000000..b6a6a8abed Binary files /dev/null and b/server/tests/exif_samples/right-mirrored.png differ diff --git a/server/tests/exif_samples/right-mirrored.tiff b/server/tests/exif_samples/right-mirrored.tiff new file mode 100644 index 0000000000..e8c9924d92 Binary files /dev/null and b/server/tests/exif_samples/right-mirrored.tiff differ diff --git a/server/tests/exif_samples/right-mirrored.webp b/server/tests/exif_samples/right-mirrored.webp new file mode 100644 index 0000000000..69f5888424 Binary files /dev/null and b/server/tests/exif_samples/right-mirrored.webp differ diff --git a/server/tests/exif_samples/right.jpg b/server/tests/exif_samples/right.jpg new file mode 100644 index 0000000000..183ffebb8e Binary files /dev/null and b/server/tests/exif_samples/right.jpg differ diff --git a/server/tests/exif_samples/right.png b/server/tests/exif_samples/right.png new file mode 100644 index 0000000000..039a4daea8 Binary files /dev/null and b/server/tests/exif_samples/right.png differ diff --git a/server/tests/exif_samples/right.tiff b/server/tests/exif_samples/right.tiff new file mode 100644 index 0000000000..ab87798565 Binary files /dev/null and b/server/tests/exif_samples/right.tiff differ diff --git a/server/tests/exif_samples/right.webp b/server/tests/exif_samples/right.webp new file mode 100644 index 0000000000..bf64277b78 Binary files /dev/null and b/server/tests/exif_samples/right.webp differ diff --git a/server/tests/exif_samples/up-mirrored.jpg b/server/tests/exif_samples/up-mirrored.jpg new file mode 100644 index 0000000000..e1865a5f0e Binary files /dev/null and b/server/tests/exif_samples/up-mirrored.jpg differ diff --git a/server/tests/exif_samples/up-mirrored.png b/server/tests/exif_samples/up-mirrored.png new file mode 100644 index 0000000000..515106ee02 Binary files /dev/null and b/server/tests/exif_samples/up-mirrored.png differ diff --git a/server/tests/exif_samples/up-mirrored.tiff b/server/tests/exif_samples/up-mirrored.tiff new file mode 100644 index 0000000000..68abe2e209 Binary files /dev/null and b/server/tests/exif_samples/up-mirrored.tiff differ diff --git a/server/tests/exif_samples/up-mirrored.webp b/server/tests/exif_samples/up-mirrored.webp new file mode 100644 index 0000000000..3cdfb8bf02 Binary files /dev/null and b/server/tests/exif_samples/up-mirrored.webp differ diff --git a/server/tests/exif_samples/up.jpg b/server/tests/exif_samples/up.jpg new file mode 100644 index 0000000000..70fc26ff2d Binary files /dev/null and b/server/tests/exif_samples/up.jpg differ diff --git a/server/tests/exif_samples/up.png b/server/tests/exif_samples/up.png new file mode 100644 index 0000000000..3490e01aa0 Binary files /dev/null and b/server/tests/exif_samples/up.png differ diff --git a/server/tests/exif_samples/up.tiff b/server/tests/exif_samples/up.tiff new file mode 100644 index 0000000000..7ef719c73a Binary files /dev/null and b/server/tests/exif_samples/up.tiff differ diff --git a/server/tests/exif_samples/up.webp b/server/tests/exif_samples/up.webp new file mode 100644 index 0000000000..09178f9995 Binary files /dev/null and b/server/tests/exif_samples/up.webp differ diff --git a/server/tests/orientation_test_9.jpeg b/server/tests/orientation_test_9.jpeg new file mode 100644 index 0000000000..b9cc183876 Binary files /dev/null and b/server/tests/orientation_test_9.jpeg differ diff --git a/server/tests/orientation_test_9_expected_preview.jpeg b/server/tests/orientation_test_9_expected_preview.jpeg new file mode 100644 index 0000000000..5c938e180c Binary files /dev/null and b/server/tests/orientation_test_9_expected_preview.jpeg differ diff --git a/server/tests/orientation_test_9_expected_thumb.jpeg b/server/tests/orientation_test_9_expected_thumb.jpeg new file mode 100644 index 0000000000..39337713cb Binary files /dev/null and b/server/tests/orientation_test_9_expected_thumb.jpeg differ