mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 02:02:25 -05:00 
			
		
		
		
	[bugfix] Allow processing of .png files where checksum is not correct (#487)
* add png stripping code from google/wuffs * experiment with stripping data from pngs * add test images * use StrippedPngDecode for pngs * add StrippedPngDecode func * update tests for (no)alphachannel pngs * nolint on copied function
This commit is contained in:
		
					parent
					
						
							
								d5fe729cf4
							
						
					
				
			
			
				commit
				
					
						eeb78bd141
					
				
			
		
					 8 changed files with 352 additions and 3 deletions
				
			
		|  | @ -196,6 +196,7 @@ The following libraries and frameworks are used by GoToSocial, with gratitude  | |||
|   - [gin-contrib/static](https://github.com/gin-contrib/static); Gin static page middleware. [MIT License](https://spdx.org/licenses/MIT.html) | ||||
| - [go-fed/httpsig](https://github.com/go-fed/httpsig); secure HTTP signature library. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). | ||||
| - [google/uuid](https://github.com/google/uuid); UUID generation. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html) | ||||
| - [google/wuffs](https://github.com/google/wuffs); png-stripping code. [Apache-2.0 License](https://spdx.org/licenses/Apache-2.0.html). | ||||
| - [go-playground/validator](https://github.com/go-playground/validator); struct validation. [MIT License](https://spdx.org/licenses/MIT.html) | ||||
| - [gorilla/websocket](https://github.com/gorilla/websocket); Websocket connectivity. [BSD-2-Clause License](https://spdx.org/licenses/BSD-2-Clause.html). | ||||
| - [gruf/go-runners](https://codeberg.org/gruf/go-runners); worker pool library. [MIT License](https://spdx.org/licenses/MIT.html). | ||||
|  |  | |||
|  | @ -74,7 +74,7 @@ func decodeImage(r io.Reader, contentType string) (*imageMeta, error) { | |||
| 	case mimeImageJpeg: | ||||
| 		i, err = jpeg.Decode(r) | ||||
| 	case mimeImagePng: | ||||
| 		i, err = png.Decode(r) | ||||
| 		i, err = StrippedPngDecode(r) | ||||
| 	default: | ||||
| 		err = fmt.Errorf("content type %s not recognised", contentType) | ||||
| 	} | ||||
|  | @ -117,7 +117,7 @@ func deriveThumbnail(r io.Reader, contentType string, createBlurhash bool) (*ima | |||
| 	case mimeImageJpeg: | ||||
| 		i, err = jpeg.Decode(r) | ||||
| 	case mimeImagePng: | ||||
| 		i, err = png.Decode(r) | ||||
| 		i, err = StrippedPngDecode(r) | ||||
| 	case mimeImageGif: | ||||
| 		i, err = gif.Decode(r) | ||||
| 	default: | ||||
|  | @ -175,7 +175,7 @@ func deriveStaticEmoji(r io.Reader, contentType string) (*imageMeta, error) { | |||
| 
 | ||||
| 	switch contentType { | ||||
| 	case mimeImagePng: | ||||
| 		i, err = png.Decode(r) | ||||
| 		i, err = StrippedPngDecode(r) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
|  |  | |||
|  | @ -111,6 +111,150 @@ func (suite *ManagerTestSuite) TestSimpleJpegProcessBlocking() { | |||
| 	suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) | ||||
| } | ||||
| 
 | ||||
| func (suite *ManagerTestSuite) TestPngNoAlphaChannelProcessBlocking() { | ||||
| 	ctx := context.Background() | ||||
| 
 | ||||
| 	data := func(_ context.Context) (io.Reader, int, error) { | ||||
| 		// load bytes from a test image | ||||
| 		b, err := os.ReadFile("./test/test-png-noalphachannel.png") | ||||
| 		if err != nil { | ||||
| 			panic(err) | ||||
| 		} | ||||
| 		return bytes.NewBuffer(b), len(b), nil | ||||
| 	} | ||||
| 
 | ||||
| 	accountID := "01FS1X72SK9ZPW0J1QQ68BD264" | ||||
| 
 | ||||
| 	// process the media with no additional info provided | ||||
| 	processingMedia, err := suite.manager.ProcessMedia(ctx, data, nil, accountID, nil) | ||||
| 	suite.NoError(err) | ||||
| 	// fetch the attachment id from the processing media | ||||
| 	attachmentID := processingMedia.AttachmentID() | ||||
| 
 | ||||
| 	// do a blocking call to fetch the attachment | ||||
| 	attachment, err := processingMedia.LoadAttachment(ctx) | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotNil(attachment) | ||||
| 
 | ||||
| 	// make sure it's got the stuff set on it that we expect | ||||
| 	// the attachment ID and accountID we expect | ||||
| 	suite.Equal(attachmentID, attachment.ID) | ||||
| 	suite.Equal(accountID, attachment.AccountID) | ||||
| 
 | ||||
| 	// file meta should be correctly derived from the image | ||||
| 	suite.EqualValues(gtsmodel.Original{ | ||||
| 		Width: 186, Height: 187, Size: 34782, Aspect: 0.9946524064171123, | ||||
| 	}, attachment.FileMeta.Original) | ||||
| 	suite.EqualValues(gtsmodel.Small{ | ||||
| 		Width: 186, Height: 187, Size: 34782, Aspect: 0.9946524064171123, | ||||
| 	}, attachment.FileMeta.Small) | ||||
| 	suite.Equal("image/png", attachment.File.ContentType) | ||||
| 	suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) | ||||
| 	suite.Equal(17471, attachment.File.FileSize) | ||||
| 	suite.Equal("LFP?{^.A-?xd.9o#RVRQ~oj:_0xW", attachment.Blurhash) | ||||
| 
 | ||||
| 	// now make sure the attachment is in the database | ||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotNil(dbAttachment) | ||||
| 
 | ||||
| 	// make sure the processed file is in storage | ||||
| 	processedFullBytes, err := suite.storage.Get(attachment.File.Path) | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotEmpty(processedFullBytes) | ||||
| 
 | ||||
| 	// load the processed bytes from our test folder, to compare | ||||
| 	processedFullBytesExpected, err := os.ReadFile("./test/test-png-noalphachannel-processed.png") | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotEmpty(processedFullBytesExpected) | ||||
| 
 | ||||
| 	// the bytes in storage should be what we expected | ||||
| 	suite.Equal(processedFullBytesExpected, processedFullBytes) | ||||
| 
 | ||||
| 	// now do the same for the thumbnail and make sure it's what we expected | ||||
| 	processedThumbnailBytes, err := suite.storage.Get(attachment.Thumbnail.Path) | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotEmpty(processedThumbnailBytes) | ||||
| 
 | ||||
| 	processedThumbnailBytesExpected, err := os.ReadFile("./test/test-png-noalphachannel-thumbnail.jpg") | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotEmpty(processedThumbnailBytesExpected) | ||||
| 
 | ||||
| 	suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) | ||||
| } | ||||
| 
 | ||||
| func (suite *ManagerTestSuite) TestPngAlphaChannelProcessBlocking() { | ||||
| 	ctx := context.Background() | ||||
| 
 | ||||
| 	data := func(_ context.Context) (io.Reader, int, error) { | ||||
| 		// load bytes from a test image | ||||
| 		b, err := os.ReadFile("./test/test-png-alphachannel.png") | ||||
| 		if err != nil { | ||||
| 			panic(err) | ||||
| 		} | ||||
| 		return bytes.NewBuffer(b), len(b), nil | ||||
| 	} | ||||
| 
 | ||||
| 	accountID := "01FS1X72SK9ZPW0J1QQ68BD264" | ||||
| 
 | ||||
| 	// process the media with no additional info provided | ||||
| 	processingMedia, err := suite.manager.ProcessMedia(ctx, data, nil, accountID, nil) | ||||
| 	suite.NoError(err) | ||||
| 	// fetch the attachment id from the processing media | ||||
| 	attachmentID := processingMedia.AttachmentID() | ||||
| 
 | ||||
| 	// do a blocking call to fetch the attachment | ||||
| 	attachment, err := processingMedia.LoadAttachment(ctx) | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotNil(attachment) | ||||
| 
 | ||||
| 	// make sure it's got the stuff set on it that we expect | ||||
| 	// the attachment ID and accountID we expect | ||||
| 	suite.Equal(attachmentID, attachment.ID) | ||||
| 	suite.Equal(accountID, attachment.AccountID) | ||||
| 
 | ||||
| 	// file meta should be correctly derived from the image | ||||
| 	suite.EqualValues(gtsmodel.Original{ | ||||
| 		Width: 186, Height: 187, Size: 34782, Aspect: 0.9946524064171123, | ||||
| 	}, attachment.FileMeta.Original) | ||||
| 	suite.EqualValues(gtsmodel.Small{ | ||||
| 		Width: 186, Height: 187, Size: 34782, Aspect: 0.9946524064171123, | ||||
| 	}, attachment.FileMeta.Small) | ||||
| 	suite.Equal("image/png", attachment.File.ContentType) | ||||
| 	suite.Equal("image/jpeg", attachment.Thumbnail.ContentType) | ||||
| 	suite.Equal(18904, attachment.File.FileSize) | ||||
| 	suite.Equal("LFP?{^.A-?xd.9o#RVRQ~oj:_0xW", attachment.Blurhash) | ||||
| 
 | ||||
| 	// now make sure the attachment is in the database | ||||
| 	dbAttachment, err := suite.db.GetAttachmentByID(ctx, attachmentID) | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotNil(dbAttachment) | ||||
| 
 | ||||
| 	// make sure the processed file is in storage | ||||
| 	processedFullBytes, err := suite.storage.Get(attachment.File.Path) | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotEmpty(processedFullBytes) | ||||
| 
 | ||||
| 	// load the processed bytes from our test folder, to compare | ||||
| 	processedFullBytesExpected, err := os.ReadFile("./test/test-png-alphachannel-processed.png") | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotEmpty(processedFullBytesExpected) | ||||
| 
 | ||||
| 	// the bytes in storage should be what we expected | ||||
| 	suite.Equal(processedFullBytesExpected, processedFullBytes) | ||||
| 
 | ||||
| 	// now do the same for the thumbnail and make sure it's what we expected | ||||
| 	processedThumbnailBytes, err := suite.storage.Get(attachment.Thumbnail.Path) | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotEmpty(processedThumbnailBytes) | ||||
| 
 | ||||
| 	processedThumbnailBytesExpected, err := os.ReadFile("./test/test-png-alphachannel-thumbnail.jpg") | ||||
| 	suite.NoError(err) | ||||
| 	suite.NotEmpty(processedThumbnailBytesExpected) | ||||
| 
 | ||||
| 	suite.Equal(processedThumbnailBytesExpected, processedThumbnailBytes) | ||||
| } | ||||
| 
 | ||||
| func (suite *ManagerTestSuite) TestSimpleJpegProcessBlockingWithCallback() { | ||||
| 	ctx := context.Background() | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										204
									
								
								internal/media/png-stripper.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								internal/media/png-stripper.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,204 @@ | |||
| /* | ||||
|    GoToSocial | ||||
|    Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org | ||||
| 
 | ||||
|    This program is free software: you can redistribute it and/or modify | ||||
|    it under the terms of the GNU Affero General Public License as published by | ||||
|    the Free Software Foundation, either version 3 of the License, or | ||||
|    (at your option) any later version. | ||||
| 
 | ||||
|    This program is distributed in the hope that it will be useful, | ||||
|    but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
|    GNU Affero General Public License for more details. | ||||
| 
 | ||||
|    You should have received a copy of the GNU Affero General Public License | ||||
|    along with this program.  If not, see <http://www.gnu.org/licenses/>. | ||||
| */ | ||||
| 
 | ||||
| package media | ||||
| 
 | ||||
| /* | ||||
|    The code in this file is taken from the following source: | ||||
|    https://github.com/google/wuffs/blob/414a011491ff513b86d8694c5d71800f3cb5a715/script/strip-png-ancillary-chunks.go | ||||
| 
 | ||||
|    It presents a workaround for this issue: https://github.com/golang/go/issues/43382 | ||||
| 
 | ||||
|    The license for the copied code is reproduced below: | ||||
| 
 | ||||
|       Copyright 2021 The Wuffs Authors. | ||||
| 
 | ||||
|       Licensed under the Apache License, Version 2.0 (the "License"); | ||||
|       you may not use this file except in compliance with the License. | ||||
|       You may obtain a copy of the License at | ||||
| 
 | ||||
|          https://www.apache.org/licenses/LICENSE-2.0 | ||||
| 
 | ||||
|       Unless required by applicable law or agreed to in writing, software | ||||
|       distributed under the License is distributed on an "AS IS" BASIS, | ||||
|       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|       See the License for the specific language governing permissions and | ||||
|       limitations under the License. | ||||
| */ | ||||
| 
 | ||||
| // strip-png-ancillary-chunks.go copies PNG data from stdin to stdout, removing | ||||
| // any ancillary chunks. | ||||
| // | ||||
| // Specification-compliant PNG decoders are required to honor critical chunks | ||||
| // but may ignore ancillary (non-critical) chunks. Stripping out ancillary | ||||
| // chunks before decoding should mean that different PNG decoders will agree on | ||||
| // the decoded output regardless of which ancillary chunk types they choose to | ||||
| // honor. Specifically, some PNG decoders may implement color and gamma | ||||
| // correction but not all do. | ||||
| // | ||||
| // This program will strip out all ancillary chunks, but it should be | ||||
| // straightforward to copy-paste-and-modify it to strip out only certain chunk | ||||
| // types (e.g. only "tRNS" transparency chunks). | ||||
| // | ||||
| // -------- | ||||
| // | ||||
| // A PNG file consists of an 8-byte magic identifier and then a series of | ||||
| // chunks. Each chunk is: | ||||
| // | ||||
| //  - a 4-byte uint32 payload length N. | ||||
| //  - a 4-byte chunk type (e.g. "gAMA" for gamma correction metadata). | ||||
| //  - an N-byte payload. | ||||
| //  - a 4-byte CRC-32 checksum of the previous (N + 4) bytes, including the | ||||
| //    chunk type but excluding the payload length. | ||||
| // | ||||
| // Chunk types consist of 4 ASCII letters. The upper-case / lower-case bit of | ||||
| // the first letter denote critical or ancillary chunks: "IDAT" and "PLTE" are | ||||
| // critical, "gAMA" and "tEXt" are ancillary. See | ||||
| // https://www.w3.org/TR/2003/REC-PNG-20031110/#5Chunk-naming-conventions | ||||
| // | ||||
| // -------- | ||||
| 
 | ||||
| import ( | ||||
| 	"encoding/binary" | ||||
| 	"image" | ||||
| 	"image/png" | ||||
| 	"io" | ||||
| ) | ||||
| 
 | ||||
| // chunkTypeAncillaryBit is whether the first byte of a big-endian uint32 chunk | ||||
| // type (the first of four ASCII letters) is lower-case. | ||||
| const chunkTypeAncillaryBit = 0x20000000 | ||||
| 
 | ||||
| // PNGAncillaryChunkStripper wraps another io.Reader to strip ancillary chunks, | ||||
| // if the data is in the PNG file format. If the data isn't PNG, it is passed | ||||
| // through unmodified. | ||||
| type PNGAncillaryChunkStripper struct { | ||||
| 	// Reader is the wrapped io.Reader. | ||||
| 	Reader io.Reader | ||||
| 
 | ||||
| 	// stickyErr is the first error returned from the wrapped io.Reader. | ||||
| 	stickyErr error | ||||
| 
 | ||||
| 	// buffer[rIndex:wIndex] holds data read from the wrapped io.Reader that | ||||
| 	// wasn't passed through yet. | ||||
| 	buffer [8]byte | ||||
| 	rIndex int | ||||
| 	wIndex int | ||||
| 
 | ||||
| 	// pending and discard is the number of remaining bytes for (and whether to | ||||
| 	// discard or pass through) the current chunk-in-progress. | ||||
| 	pending int64 | ||||
| 	discard bool | ||||
| 
 | ||||
| 	// notPNG is set true if the data stream doesn't start with the 8-byte PNG | ||||
| 	// magic identifier. If true, the wrapped io.Reader's data (including the | ||||
| 	// first up-to-8 bytes) is passed through without modification. | ||||
| 	notPNG bool | ||||
| 
 | ||||
| 	// seenMagic is whether we've seen the 8-byte PNG magic identifier. | ||||
| 	seenMagic bool | ||||
| } | ||||
| 
 | ||||
| // Read implements io.Reader. | ||||
| func (r *PNGAncillaryChunkStripper) Read(p []byte) (int, error) { | ||||
| 	for { | ||||
| 		// If the wrapped io.Reader returned a non-nil error, drain r.buffer | ||||
| 		// (what data we have) and return that error (if fully drained). | ||||
| 		if r.stickyErr != nil { | ||||
| 			n := copy(p, r.buffer[r.rIndex:r.wIndex]) | ||||
| 			r.rIndex += n | ||||
| 			if r.rIndex < r.wIndex { | ||||
| 				return n, nil | ||||
| 			} | ||||
| 			return n, r.stickyErr | ||||
| 		} | ||||
| 
 | ||||
| 		// Handle trivial requests, including draining our buffer. | ||||
| 		if len(p) == 0 { | ||||
| 			return 0, nil | ||||
| 		} else if r.rIndex < r.wIndex { | ||||
| 			n := copy(p, r.buffer[r.rIndex:r.wIndex]) | ||||
| 			r.rIndex += n | ||||
| 			return n, nil | ||||
| 		} | ||||
| 
 | ||||
| 		// From here onwards, our buffer is drained: r.rIndex == r.wIndex. | ||||
| 
 | ||||
| 		// Handle non-PNG input. | ||||
| 		if r.notPNG { | ||||
| 			return r.Reader.Read(p) | ||||
| 		} | ||||
| 
 | ||||
| 		// Continue processing any PNG chunk that's in progress, whether | ||||
| 		// discarding it or passing it through. | ||||
| 		for r.pending > 0 { | ||||
| 			if int64(len(p)) > r.pending { | ||||
| 				p = p[:r.pending] | ||||
| 			} | ||||
| 			n, err := r.Reader.Read(p) | ||||
| 			r.pending -= int64(n) | ||||
| 			r.stickyErr = err | ||||
| 			if r.discard { | ||||
| 				continue | ||||
| 			} | ||||
| 			return n, err | ||||
| 		} | ||||
| 
 | ||||
| 		// We're either expecting the 8-byte PNG magic identifier or the 4-byte | ||||
| 		// PNG chunk length + 4-byte PNG chunk type. Either way, read 8 bytes. | ||||
| 		r.rIndex = 0 | ||||
| 		r.wIndex, r.stickyErr = io.ReadFull(r.Reader, r.buffer[:8]) | ||||
| 		if r.stickyErr != nil { | ||||
| 			// Undo io.ReadFull converting io.EOF to io.ErrUnexpectedEOF. | ||||
| 			if r.stickyErr == io.ErrUnexpectedEOF { | ||||
| 				r.stickyErr = io.EOF | ||||
| 			} | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		// Process those 8 bytes, either: | ||||
| 		//  - a PNG chunk (if we've already seen the PNG magic identifier), | ||||
| 		//  - the PNG magic identifier itself (if the input is a PNG) or | ||||
| 		//  - something else (if it's not a PNG). | ||||
| 		//nolint:gocritic | ||||
| 		if r.seenMagic { | ||||
| 			// The number of pending bytes is equal to (N + 4) because of the 4 | ||||
| 			// byte trailer, a checksum. | ||||
| 			r.pending = int64(binary.BigEndian.Uint32(r.buffer[:4])) + 4 | ||||
| 			chunkType := binary.BigEndian.Uint32(r.buffer[4:]) | ||||
| 			r.discard = (chunkType & chunkTypeAncillaryBit) != 0 | ||||
| 			if r.discard { | ||||
| 				r.rIndex = r.wIndex | ||||
| 			} | ||||
| 		} else if string(r.buffer[:8]) == "\x89PNG\x0D\x0A\x1A\x0A" { | ||||
| 			r.seenMagic = true | ||||
| 		} else { | ||||
| 			r.notPNG = true | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // StrippedPngDecode strips ancillary data from png to allow more lenient decoding of pngs | ||||
| // see: https://github.com/golang/go/issues/43382 | ||||
| // and: https://github.com/google/wuffs/blob/414a011491ff513b86d8694c5d71800f3cb5a715/script/strip-png-ancillary-chunks.go | ||||
| func StrippedPngDecode(r io.Reader) (image.Image, error) { | ||||
| 	strippedPngReader := io.Reader(&PNGAncillaryChunkStripper{ | ||||
| 		Reader: r, | ||||
| 	}) | ||||
| 	return png.Decode(strippedPngReader) | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								internal/media/test/test-png-alphachannel-processed.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								internal/media/test/test-png-alphachannel-processed.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 18 KiB | 
							
								
								
									
										
											BIN
										
									
								
								internal/media/test/test-png-alphachannel-thumbnail.jpg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								internal/media/test/test-png-alphachannel-thumbnail.jpg
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 6.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								internal/media/test/test-png-noalphachannel-processed.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								internal/media/test/test-png-noalphachannel-processed.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 17 KiB | 
							
								
								
									
										
											BIN
										
									
								
								internal/media/test/test-png-noalphachannel-thumbnail.jpg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								internal/media/test/test-png-noalphachannel-thumbnail.jpg
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 6.3 KiB | 
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue