mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-30 16:32:54 +00:00 
			
		
		
		
	Add Image Diff for SVG files (#14867)
* Added type sniffer. * Switched content detection from base to typesniffer. * Added GuessContentType to Blob. * Moved image info logic to client. Added support for SVG images in diff. * Restore old blocked svg behaviour. * Added missing image formats. * Execute image diff only when container is visible. * add margin to spinner * improve BIN tag on image diffs * Default to render view. * Show image diff on incomplete diff. Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Lauris BH <lauris@nix.lv>
This commit is contained in:
		| @@ -10,8 +10,9 @@ import ( | ||||
| 	"image" | ||||
| 	"image/color/palette" | ||||
|  | ||||
| 	// Enable PNG support: | ||||
| 	_ "image/png" | ||||
| 	_ "image/gif"  // for processing gif images | ||||
| 	_ "image/jpeg" // for processing jpeg images | ||||
| 	_ "image/png"  // for processing png images | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
|   | ||||
| @@ -12,10 +12,8 @@ import ( | ||||
| 	"encoding/hex" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"regexp" | ||||
| 	"runtime" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| @@ -30,15 +28,6 @@ import ( | ||||
| 	"github.com/dustin/go-humanize" | ||||
| ) | ||||
|  | ||||
| // Use at most this many bytes to determine Content Type. | ||||
| const sniffLen = 512 | ||||
|  | ||||
| // SVGMimeType MIME type of SVG images. | ||||
| const SVGMimeType = "image/svg+xml" | ||||
|  | ||||
| var svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(<!--.*?-->|<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg[\s>\/]`) | ||||
| var svgTagInXMLRegex = regexp.MustCompile(`(?si)\A<\?xml\b.*?\?>\s*(?:(<!--.*?-->|<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg[\s>\/]`) | ||||
|  | ||||
| // EncodeMD5 encodes string to md5 hex value. | ||||
| func EncodeMD5(str string) string { | ||||
| 	m := md5.New() | ||||
| @@ -276,63 +265,6 @@ func IsLetter(ch rune) bool { | ||||
| 	return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_' || ch >= 0x80 && unicode.IsLetter(ch) | ||||
| } | ||||
|  | ||||
| // DetectContentType extends http.DetectContentType with more content types. | ||||
| func DetectContentType(data []byte) string { | ||||
| 	ct := http.DetectContentType(data) | ||||
|  | ||||
| 	if len(data) > sniffLen { | ||||
| 		data = data[:sniffLen] | ||||
| 	} | ||||
|  | ||||
| 	if setting.UI.SVG.Enabled && | ||||
| 		((strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/html")) && svgTagRegex.Match(data) || | ||||
| 			strings.Contains(ct, "text/xml") && svgTagInXMLRegex.Match(data)) { | ||||
|  | ||||
| 		// SVG is unsupported.  https://github.com/golang/go/issues/15888 | ||||
| 		return SVGMimeType | ||||
| 	} | ||||
| 	return ct | ||||
| } | ||||
|  | ||||
| // IsRepresentableAsText returns true if file content can be represented as | ||||
| // plain text or is empty. | ||||
| func IsRepresentableAsText(data []byte) bool { | ||||
| 	return IsTextFile(data) || IsSVGImageFile(data) | ||||
| } | ||||
|  | ||||
| // IsTextFile returns true if file content format is plain text or empty. | ||||
| func IsTextFile(data []byte) bool { | ||||
| 	if len(data) == 0 { | ||||
| 		return true | ||||
| 	} | ||||
| 	return strings.Contains(DetectContentType(data), "text/") | ||||
| } | ||||
|  | ||||
| // IsImageFile detects if data is an image format | ||||
| func IsImageFile(data []byte) bool { | ||||
| 	return strings.Contains(DetectContentType(data), "image/") | ||||
| } | ||||
|  | ||||
| // IsSVGImageFile detects if data is an SVG image format | ||||
| func IsSVGImageFile(data []byte) bool { | ||||
| 	return strings.Contains(DetectContentType(data), SVGMimeType) | ||||
| } | ||||
|  | ||||
| // IsPDFFile detects if data is a pdf format | ||||
| func IsPDFFile(data []byte) bool { | ||||
| 	return strings.Contains(DetectContentType(data), "application/pdf") | ||||
| } | ||||
|  | ||||
| // IsVideoFile detects if data is an video format | ||||
| func IsVideoFile(data []byte) bool { | ||||
| 	return strings.Contains(DetectContentType(data), "video/") | ||||
| } | ||||
|  | ||||
| // IsAudioFile detects if data is an video format | ||||
| func IsAudioFile(data []byte) bool { | ||||
| 	return strings.Contains(DetectContentType(data), "audio/") | ||||
| } | ||||
|  | ||||
| // EntryIcon returns the octicon class for displaying files/directories | ||||
| func EntryIcon(entry *git.TreeEntry) string { | ||||
| 	switch { | ||||
|   | ||||
| @@ -5,7 +5,6 @@ | ||||
| package base | ||||
|  | ||||
| import ( | ||||
| 	"encoding/base64" | ||||
| 	"os" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| @@ -246,97 +245,6 @@ func TestIsLetter(t *testing.T) { | ||||
| 	assert.False(t, IsLetter(0x93)) | ||||
| } | ||||
|  | ||||
| func TestDetectContentTypeLongerThanSniffLen(t *testing.T) { | ||||
| 	// Pre-condition: Shorter than sniffLen detects SVG. | ||||
| 	assert.Equal(t, "image/svg+xml", DetectContentType([]byte(`<!-- Comment --><svg></svg>`))) | ||||
| 	// Longer than sniffLen detects something else. | ||||
| 	assert.Equal(t, "text/plain; charset=utf-8", DetectContentType([]byte(`<!-- | ||||
| Comment Comment Comment Comment Comment Comment Comment Comment Comment Comment | ||||
| Comment Comment Comment Comment Comment Comment Comment Comment Comment Comment | ||||
| Comment Comment Comment Comment Comment Comment Comment Comment Comment Comment | ||||
| Comment Comment Comment Comment Comment Comment Comment Comment Comment Comment | ||||
| Comment Comment Comment Comment Comment Comment Comment Comment Comment Comment | ||||
| Comment Comment Comment Comment Comment Comment Comment Comment Comment Comment | ||||
| Comment Comment Comment --><svg></svg>`))) | ||||
| } | ||||
|  | ||||
| // IsRepresentableAsText | ||||
|  | ||||
| func TestIsTextFile(t *testing.T) { | ||||
| 	assert.True(t, IsTextFile([]byte{})) | ||||
| 	assert.True(t, IsTextFile([]byte("lorem ipsum"))) | ||||
| } | ||||
|  | ||||
| func TestIsImageFile(t *testing.T) { | ||||
| 	png, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAG0lEQVQYlWN4+vTpf3SMDTAMBYXYBLFpHgoKAeiOf0SGE9kbAAAAAElFTkSuQmCC") | ||||
| 	assert.True(t, IsImageFile(png)) | ||||
| 	assert.False(t, IsImageFile([]byte("plain text"))) | ||||
| } | ||||
|  | ||||
| func TestIsSVGImageFile(t *testing.T) { | ||||
| 	assert.True(t, IsSVGImageFile([]byte("<svg></svg>"))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte("    <svg></svg>"))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<svg width="100"></svg>`))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte("<svg/>"))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<?xml version="1.0" encoding="UTF-8"?><svg></svg>`))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<!-- Comment --> | ||||
| 	<svg></svg>`))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<!-- Multiple --> | ||||
| 	<!-- Comments --> | ||||
| 	<svg></svg>`))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<!-- Multiline | ||||
| 	Comment --> | ||||
| 	<svg></svg>`))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Basic//EN" | ||||
| 	"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd"> | ||||
| 	<svg></svg>`))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!-- Comment --> | ||||
| 	<svg></svg>`))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!-- Multiple --> | ||||
| 	<!-- Comments --> | ||||
| 	<svg></svg>`))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!-- Multline | ||||
| 	Comment --> | ||||
| 	<svg></svg>`))) | ||||
| 	assert.True(t, IsSVGImageFile([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | ||||
| 	<!-- Multline | ||||
| 	Comment --> | ||||
| 	<svg></svg>`))) | ||||
| 	assert.False(t, IsSVGImageFile([]byte{})) | ||||
| 	assert.False(t, IsSVGImageFile([]byte("svg"))) | ||||
| 	assert.False(t, IsSVGImageFile([]byte("<svgfoo></svgfoo>"))) | ||||
| 	assert.False(t, IsSVGImageFile([]byte("text<svg></svg>"))) | ||||
| 	assert.False(t, IsSVGImageFile([]byte("<html><body><svg></svg></body></html>"))) | ||||
| 	assert.False(t, IsSVGImageFile([]byte(`<script>"<svg></svg>"</script>`))) | ||||
| 	assert.False(t, IsSVGImageFile([]byte(`<!-- <svg></svg> inside comment --> | ||||
| 	<foo></foo>`))) | ||||
| 	assert.False(t, IsSVGImageFile([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!-- <svg></svg> inside comment --> | ||||
| 	<foo></foo>`))) | ||||
| } | ||||
|  | ||||
| func TestIsPDFFile(t *testing.T) { | ||||
| 	pdf, _ := base64.StdEncoding.DecodeString("JVBERi0xLjYKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQp4nF3NPwsCMQwF8D2f4s2CNYk1baF0EHRwOwg4iJt/NsFb/PpevUE4Mjwe") | ||||
| 	assert.True(t, IsPDFFile(pdf)) | ||||
| 	assert.False(t, IsPDFFile([]byte("plain text"))) | ||||
| } | ||||
|  | ||||
| func TestIsVideoFile(t *testing.T) { | ||||
| 	mp4, _ := base64.StdEncoding.DecodeString("AAAAGGZ0eXBtcDQyAAAAAGlzb21tcDQyAAEI721vb3YAAABsbXZoZAAAAADaBlwX2gZcFwAAA+gA") | ||||
| 	assert.True(t, IsVideoFile(mp4)) | ||||
| 	assert.False(t, IsVideoFile([]byte("plain text"))) | ||||
| } | ||||
|  | ||||
| func TestIsAudioFile(t *testing.T) { | ||||
| 	mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl") | ||||
| 	assert.True(t, IsAudioFile(mp3)) | ||||
| 	assert.False(t, IsAudioFile([]byte("plain text"))) | ||||
| } | ||||
|  | ||||
| // TODO: Test EntryIcon | ||||
|  | ||||
| func TestSetupGiteaRoot(t *testing.T) { | ||||
|   | ||||
| @@ -10,6 +10,8 @@ import ( | ||||
| 	"encoding/base64" | ||||
| 	"io" | ||||
| 	"io/ioutil" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/typesniffer" | ||||
| ) | ||||
|  | ||||
| // This file contains common functions between the gogit and !gogit variants for git Blobs | ||||
| @@ -82,3 +84,14 @@ func (b *Blob) GetBlobContentBase64() (string, error) { | ||||
| 	} | ||||
| 	return string(out), nil | ||||
| } | ||||
|  | ||||
| // GuessContentType guesses the content type of the blob. | ||||
| func (b *Blob) GuessContentType() (typesniffer.SniffedType, error) { | ||||
| 	r, err := b.DataAsync() | ||||
| 	if err != nil { | ||||
| 		return typesniffer.SniffedType{}, err | ||||
| 	} | ||||
| 	defer r.Close() | ||||
|  | ||||
| 	return typesniffer.DetectContentTypeFromReader(r) | ||||
| } | ||||
|   | ||||
| @@ -11,13 +11,7 @@ import ( | ||||
| 	"container/list" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"image" | ||||
| 	"image/color" | ||||
| 	_ "image/gif"  // for processing gif images | ||||
| 	_ "image/jpeg" // for processing jpeg images | ||||
| 	_ "image/png"  // for processing png images | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"os/exec" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| @@ -81,70 +75,6 @@ func (c *Commit) ParentCount() int { | ||||
| 	return len(c.Parents) | ||||
| } | ||||
|  | ||||
| func isImageFile(data []byte) (string, bool) { | ||||
| 	contentType := http.DetectContentType(data) | ||||
| 	if strings.Contains(contentType, "image/") { | ||||
| 		return contentType, true | ||||
| 	} | ||||
| 	return contentType, false | ||||
| } | ||||
|  | ||||
| // IsImageFile is a file image type | ||||
| func (c *Commit) IsImageFile(name string) bool { | ||||
| 	blob, err := c.GetBlobByPath(name) | ||||
| 	if err != nil { | ||||
| 		return false | ||||
| 	} | ||||
|  | ||||
| 	dataRc, err := blob.DataAsync() | ||||
| 	if err != nil { | ||||
| 		return false | ||||
| 	} | ||||
| 	defer dataRc.Close() | ||||
| 	buf := make([]byte, 1024) | ||||
| 	n, _ := dataRc.Read(buf) | ||||
| 	buf = buf[:n] | ||||
| 	_, isImage := isImageFile(buf) | ||||
| 	return isImage | ||||
| } | ||||
|  | ||||
| // ImageMetaData represents metadata of an image file | ||||
| type ImageMetaData struct { | ||||
| 	ColorModel color.Model | ||||
| 	Width      int | ||||
| 	Height     int | ||||
| 	ByteSize   int64 | ||||
| } | ||||
|  | ||||
| // ImageInfo returns information about the dimensions of an image | ||||
| func (c *Commit) ImageInfo(name string) (*ImageMetaData, error) { | ||||
| 	if !c.IsImageFile(name) { | ||||
| 		return nil, nil | ||||
| 	} | ||||
|  | ||||
| 	blob, err := c.GetBlobByPath(name) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	reader, err := blob.DataAsync() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer reader.Close() | ||||
| 	config, _, err := image.DecodeConfig(reader) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	metadata := ImageMetaData{ | ||||
| 		ColorModel: config.ColorModel, | ||||
| 		Width:      config.Width, | ||||
| 		Height:     config.Height, | ||||
| 		ByteSize:   blob.Size(), | ||||
| 	} | ||||
| 	return &metadata, nil | ||||
| } | ||||
|  | ||||
| // GetCommitByPath return the commit of relative path object. | ||||
| func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) { | ||||
| 	return c.repo.getCommitByPathWithID(c.ID, relpath) | ||||
|   | ||||
| @@ -16,12 +16,12 @@ import ( | ||||
|  | ||||
| 	"code.gitea.io/gitea/models" | ||||
| 	"code.gitea.io/gitea/modules/analyze" | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/modules/charset" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
| 	"code.gitea.io/gitea/modules/typesniffer" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
|  | ||||
| 	"github.com/blevesearch/bleve/v2" | ||||
| @@ -211,7 +211,7 @@ func (b *BleveIndexer) addUpdate(batchWriter git.WriteCloserError, batchReader * | ||||
| 	fileContents, err := ioutil.ReadAll(io.LimitReader(batchReader, size)) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} else if !base.IsTextFile(fileContents) { | ||||
| 	} else if !typesniffer.DetectContentType(fileContents).IsText() { | ||||
| 		// FIXME: UTF-16 files will probably fail here | ||||
| 		return nil | ||||
| 	} | ||||
|   | ||||
| @@ -16,12 +16,12 @@ import ( | ||||
|  | ||||
| 	"code.gitea.io/gitea/models" | ||||
| 	"code.gitea.io/gitea/modules/analyze" | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/modules/charset" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
| 	"code.gitea.io/gitea/modules/typesniffer" | ||||
|  | ||||
| 	"github.com/go-enry/go-enry/v2" | ||||
| 	jsoniter "github.com/json-iterator/go" | ||||
| @@ -210,7 +210,7 @@ func (b *ElasticSearchIndexer) addUpdate(batchWriter git.WriteCloserError, batch | ||||
| 	fileContents, err := ioutil.ReadAll(io.LimitReader(batchReader, size)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} else if !base.IsTextFile(fileContents) { | ||||
| 	} else if !typesniffer.DetectContentType(fileContents).IsText() { | ||||
| 		// FIXME: UTF-16 files will probably fail here | ||||
| 		return nil, nil | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										96
									
								
								modules/typesniffer/typesniffer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								modules/typesniffer/typesniffer.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| // Copyright 2021 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
|  | ||||
| package typesniffer | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"regexp" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| // Use at most this many bytes to determine Content Type. | ||||
| const sniffLen = 1024 | ||||
|  | ||||
| // SvgMimeType MIME type of SVG images. | ||||
| const SvgMimeType = "image/svg+xml" | ||||
|  | ||||
| var svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(<!--.*?-->|<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg[\s>\/]`) | ||||
| var svgTagInXMLRegex = regexp.MustCompile(`(?si)\A<\?xml\b.*?\?>\s*(?:(<!--.*?-->|<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg[\s>\/]`) | ||||
|  | ||||
| // SniffedType contains informations about a blobs type. | ||||
| type SniffedType struct { | ||||
| 	contentType string | ||||
| } | ||||
|  | ||||
| // IsText etects if content format is plain text. | ||||
| func (ct SniffedType) IsText() bool { | ||||
| 	return strings.Contains(ct.contentType, "text/") | ||||
| } | ||||
|  | ||||
| // IsImage detects if data is an image format | ||||
| func (ct SniffedType) IsImage() bool { | ||||
| 	return strings.Contains(ct.contentType, "image/") | ||||
| } | ||||
|  | ||||
| // IsSvgImage detects if data is an SVG image format | ||||
| func (ct SniffedType) IsSvgImage() bool { | ||||
| 	return strings.Contains(ct.contentType, SvgMimeType) | ||||
| } | ||||
|  | ||||
| // IsPDF detects if data is a PDF format | ||||
| func (ct SniffedType) IsPDF() bool { | ||||
| 	return strings.Contains(ct.contentType, "application/pdf") | ||||
| } | ||||
|  | ||||
| // IsVideo detects if data is an video format | ||||
| func (ct SniffedType) IsVideo() bool { | ||||
| 	return strings.Contains(ct.contentType, "video/") | ||||
| } | ||||
|  | ||||
| // IsAudio detects if data is an video format | ||||
| func (ct SniffedType) IsAudio() bool { | ||||
| 	return strings.Contains(ct.contentType, "audio/") | ||||
| } | ||||
|  | ||||
| // IsRepresentableAsText returns true if file content can be represented as | ||||
| // plain text or is empty. | ||||
| func (ct SniffedType) IsRepresentableAsText() bool { | ||||
| 	return ct.IsText() || ct.IsSvgImage() | ||||
| } | ||||
|  | ||||
| // DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty. | ||||
| func DetectContentType(data []byte) SniffedType { | ||||
| 	if len(data) == 0 { | ||||
| 		return SniffedType{"text/unknown"} | ||||
| 	} | ||||
|  | ||||
| 	ct := http.DetectContentType(data) | ||||
|  | ||||
| 	if len(data) > sniffLen { | ||||
| 		data = data[:sniffLen] | ||||
| 	} | ||||
|  | ||||
| 	if (strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/html")) && svgTagRegex.Match(data) || | ||||
| 		strings.Contains(ct, "text/xml") && svgTagInXMLRegex.Match(data) { | ||||
| 		// SVG is unsupported. https://github.com/golang/go/issues/15888 | ||||
| 		ct = SvgMimeType | ||||
| 	} | ||||
|  | ||||
| 	return SniffedType{ct} | ||||
| } | ||||
|  | ||||
| // DetectContentTypeFromReader guesses the content type contained in the reader. | ||||
| func DetectContentTypeFromReader(r io.Reader) (SniffedType, error) { | ||||
| 	buf := make([]byte, sniffLen) | ||||
| 	n, err := r.Read(buf) | ||||
| 	if err != nil && err != io.EOF { | ||||
| 		return SniffedType{}, fmt.Errorf("DetectContentTypeFromReader io error: %w", err) | ||||
| 	} | ||||
| 	buf = buf[:n] | ||||
|  | ||||
| 	return DetectContentType(buf), nil | ||||
| } | ||||
							
								
								
									
										97
									
								
								modules/typesniffer/typesniffer_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								modules/typesniffer/typesniffer_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| // Copyright 2021 The Gitea Authors. All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
|  | ||||
| package typesniffer | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/base64" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| func TestDetectContentTypeLongerThanSniffLen(t *testing.T) { | ||||
| 	// Pre-condition: Shorter than sniffLen detects SVG. | ||||
| 	assert.Equal(t, "image/svg+xml", DetectContentType([]byte(`<!-- Comment --><svg></svg>`)).contentType) | ||||
| 	// Longer than sniffLen detects something else. | ||||
| 	assert.NotEqual(t, "image/svg+xml", DetectContentType([]byte(`<!-- `+strings.Repeat("x", sniffLen)+` --><svg></svg>`)).contentType) | ||||
| } | ||||
|  | ||||
| func TestIsTextFile(t *testing.T) { | ||||
| 	assert.True(t, DetectContentType([]byte{}).IsText()) | ||||
| 	assert.True(t, DetectContentType([]byte("lorem ipsum")).IsText()) | ||||
| } | ||||
|  | ||||
| func TestIsSvgImage(t *testing.T) { | ||||
| 	assert.True(t, DetectContentType([]byte("<svg></svg>")).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte("    <svg></svg>")).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<svg width="100"></svg>`)).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte("<svg/>")).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?><svg></svg>`)).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<!-- Comment --> | ||||
| 	<svg></svg>`)).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<!-- Multiple --> | ||||
| 	<!-- Comments --> | ||||
| 	<svg></svg>`)).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<!-- Multiline | ||||
| 	Comment --> | ||||
| 	<svg></svg>`)).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Basic//EN" | ||||
| 	"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-basic.dtd"> | ||||
| 	<svg></svg>`)).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!-- Comment --> | ||||
| 	<svg></svg>`)).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!-- Multiple --> | ||||
| 	<!-- Comments --> | ||||
| 	<svg></svg>`)).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!-- Multline | ||||
| 	Comment --> | ||||
| 	<svg></svg>`)).IsSvgImage()) | ||||
| 	assert.True(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | ||||
| 	<!-- Multline | ||||
| 	Comment --> | ||||
| 	<svg></svg>`)).IsSvgImage()) | ||||
| 	assert.False(t, DetectContentType([]byte{}).IsSvgImage()) | ||||
| 	assert.False(t, DetectContentType([]byte("svg")).IsSvgImage()) | ||||
| 	assert.False(t, DetectContentType([]byte("<svgfoo></svgfoo>")).IsSvgImage()) | ||||
| 	assert.False(t, DetectContentType([]byte("text<svg></svg>")).IsSvgImage()) | ||||
| 	assert.False(t, DetectContentType([]byte("<html><body><svg></svg></body></html>")).IsSvgImage()) | ||||
| 	assert.False(t, DetectContentType([]byte(`<script>"<svg></svg>"</script>`)).IsSvgImage()) | ||||
| 	assert.False(t, DetectContentType([]byte(`<!-- <svg></svg> inside comment --> | ||||
| 	<foo></foo>`)).IsSvgImage()) | ||||
| 	assert.False(t, DetectContentType([]byte(`<?xml version="1.0" encoding="UTF-8"?> | ||||
| 	<!-- <svg></svg> inside comment --> | ||||
| 	<foo></foo>`)).IsSvgImage()) | ||||
| } | ||||
|  | ||||
| func TestIsPDF(t *testing.T) { | ||||
| 	pdf, _ := base64.StdEncoding.DecodeString("JVBERi0xLjYKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURlY29kZT4+CnN0cmVhbQp4nF3NPwsCMQwF8D2f4s2CNYk1baF0EHRwOwg4iJt/NsFb/PpevUE4Mjwe") | ||||
| 	assert.True(t, DetectContentType(pdf).IsPDF()) | ||||
| 	assert.False(t, DetectContentType([]byte("plain text")).IsPDF()) | ||||
| } | ||||
|  | ||||
| func TestIsVideo(t *testing.T) { | ||||
| 	mp4, _ := base64.StdEncoding.DecodeString("AAAAGGZ0eXBtcDQyAAAAAGlzb21tcDQyAAEI721vb3YAAABsbXZoZAAAAADaBlwX2gZcFwAAA+gA") | ||||
| 	assert.True(t, DetectContentType(mp4).IsVideo()) | ||||
| 	assert.False(t, DetectContentType([]byte("plain text")).IsVideo()) | ||||
| } | ||||
|  | ||||
| func TestIsAudio(t *testing.T) { | ||||
| 	mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl") | ||||
| 	assert.True(t, DetectContentType(mp3).IsAudio()) | ||||
| 	assert.False(t, DetectContentType([]byte("plain text")).IsAudio()) | ||||
| } | ||||
|  | ||||
| func TestDetectContentTypeFromReader(t *testing.T) { | ||||
| 	mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl") | ||||
| 	st, err := DetectContentTypeFromReader(bytes.NewReader(mp3)) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.True(t, st.IsAudio()) | ||||
| } | ||||
| @@ -37,8 +37,20 @@ func setCompareContext(ctx *context.Context, base *git.Commit, head *git.Commit, | ||||
| 	ctx.Data["BaseCommit"] = base | ||||
| 	ctx.Data["HeadCommit"] = head | ||||
|  | ||||
| 	ctx.Data["GetBlobByPathForCommit"] = func(commit *git.Commit, path string) *git.Blob { | ||||
| 		if commit == nil { | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		blob, err := commit.GetBlobByPath(path) | ||||
| 		if err != nil { | ||||
| 			return nil | ||||
| 		} | ||||
| 		return blob | ||||
| 	} | ||||
|  | ||||
| 	setPathsCompareContext(ctx, base, head, headTarget) | ||||
| 	setImageCompareContext(ctx, base, head) | ||||
| 	setImageCompareContext(ctx) | ||||
| 	setCsvCompareContext(ctx) | ||||
| } | ||||
|  | ||||
| @@ -57,27 +69,18 @@ func setPathsCompareContext(ctx *context.Context, base *git.Commit, head *git.Co | ||||
| } | ||||
|  | ||||
| // setImageCompareContext sets context data that is required by image compare template | ||||
| func setImageCompareContext(ctx *context.Context, base *git.Commit, head *git.Commit) { | ||||
| 	ctx.Data["IsImageFileInHead"] = head.IsImageFile | ||||
| 	ctx.Data["IsImageFileInBase"] = base.IsImageFile | ||||
| 	ctx.Data["ImageInfoBase"] = func(name string) *git.ImageMetaData { | ||||
| 		if base == nil { | ||||
| 			return nil | ||||
| func setImageCompareContext(ctx *context.Context) { | ||||
| 	ctx.Data["IsBlobAnImage"] = func(blob *git.Blob) bool { | ||||
| 		if blob == nil { | ||||
| 			return false | ||||
| 		} | ||||
| 		result, err := base.ImageInfo(name) | ||||
|  | ||||
| 		st, err := blob.GuessContentType() | ||||
| 		if err != nil { | ||||
| 			log.Error("ImageInfo failed: %v", err) | ||||
| 			return nil | ||||
| 			log.Error("GuessContentType failed: %v", err) | ||||
| 			return false | ||||
| 		} | ||||
| 		return result | ||||
| 	} | ||||
| 	ctx.Data["ImageInfo"] = func(name string) *git.ImageMetaData { | ||||
| 		result, err := head.ImageInfo(name) | ||||
| 		if err != nil { | ||||
| 			log.Error("ImageInfo failed: %v", err) | ||||
| 			return nil | ||||
| 		} | ||||
| 		return result | ||||
| 		return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()) | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -12,7 +12,6 @@ import ( | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/modules/charset" | ||||
| 	"code.gitea.io/gitea/modules/context" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| @@ -20,6 +19,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/lfs" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/typesniffer" | ||||
| ) | ||||
|  | ||||
| // ServeData download file from io.Reader | ||||
| @@ -45,24 +45,27 @@ func ServeData(ctx *context.Context, name string, size int64, reader io.Reader) | ||||
| 	// Google Chrome dislike commas in filenames, so let's change it to a space | ||||
| 	name = strings.ReplaceAll(name, ",", " ") | ||||
|  | ||||
| 	if base.IsTextFile(buf) || ctx.QueryBool("render") { | ||||
| 	st := typesniffer.DetectContentType(buf) | ||||
|  | ||||
| 	if st.IsText() || ctx.QueryBool("render") { | ||||
| 		cs, err := charset.DetectEncoding(buf) | ||||
| 		if err != nil { | ||||
| 			log.Error("Detect raw file %s charset failed: %v, using by default utf-8", name, err) | ||||
| 			cs = "utf-8" | ||||
| 		} | ||||
| 		ctx.Resp.Header().Set("Content-Type", "text/plain; charset="+strings.ToLower(cs)) | ||||
| 	} else if base.IsImageFile(buf) || base.IsPDFFile(buf) { | ||||
| 		ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name)) | ||||
| 	} else { | ||||
| 		ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") | ||||
| 		if base.IsSVGImageFile(buf) { | ||||
|  | ||||
| 		if (st.IsImage() || st.IsPDF()) && (setting.UI.SVG.Enabled || !st.IsSvgImage()) { | ||||
| 			ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name)) | ||||
| 			if st.IsSvgImage() { | ||||
| 				ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") | ||||
| 				ctx.Resp.Header().Set("X-Content-Type-Options", "nosniff") | ||||
| 			ctx.Resp.Header().Set("Content-Type", base.SVGMimeType) | ||||
| 				ctx.Resp.Header().Set("Content-Type", typesniffer.SvgMimeType) | ||||
| 			} | ||||
| 		} else { | ||||
| 			ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name)) | ||||
| 		ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") | ||||
| 			if setting.MimeTypeMap.Enabled { | ||||
| 				fileExtension := strings.ToLower(filepath.Ext(name)) | ||||
| 				if mimetype, ok := setting.MimeTypeMap.Map[fileExtension]; ok { | ||||
| @@ -70,6 +73,7 @@ func ServeData(ctx *context.Context, name string, size int64, reader io.Reader) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	_, err = ctx.Resp.Write(buf) | ||||
| 	if err != nil { | ||||
|   | ||||
| @@ -20,6 +20,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/repofiles" | ||||
| 	repo_module "code.gitea.io/gitea/modules/repository" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/typesniffer" | ||||
| 	"code.gitea.io/gitea/modules/upload" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/modules/web" | ||||
| @@ -117,8 +118,8 @@ func editFile(ctx *context.Context, isNewFile bool) { | ||||
| 		buf = buf[:n] | ||||
|  | ||||
| 		// Only some file types are editable online as text. | ||||
| 		if !base.IsRepresentableAsText(buf) { | ||||
| 			ctx.NotFound("base.IsRepresentableAsText", nil) | ||||
| 		if !typesniffer.DetectContentType(buf).IsRepresentableAsText() { | ||||
| 			ctx.NotFound("typesniffer.IsRepresentableAsText", nil) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -25,6 +25,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/storage" | ||||
| 	"code.gitea.io/gitea/modules/typesniffer" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| @@ -278,16 +279,16 @@ func LFSFileGet(ctx *context.Context) { | ||||
| 	} | ||||
| 	buf = buf[:n] | ||||
|  | ||||
| 	ctx.Data["IsTextFile"] = base.IsTextFile(buf) | ||||
| 	isRepresentableAsText := base.IsRepresentableAsText(buf) | ||||
| 	st := typesniffer.DetectContentType(buf) | ||||
| 	ctx.Data["IsTextFile"] = st.IsText() | ||||
| 	isRepresentableAsText := st.IsRepresentableAsText() | ||||
|  | ||||
| 	fileSize := meta.Size | ||||
| 	ctx.Data["FileSize"] = meta.Size | ||||
| 	ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s.git/info/lfs/objects/%s/%s", setting.AppURL, ctx.Repo.Repository.FullName(), meta.Oid, "direct") | ||||
| 	switch { | ||||
| 	case isRepresentableAsText: | ||||
| 		// This will be true for SVGs. | ||||
| 		if base.IsImageFile(buf) { | ||||
| 		if st.IsSvgImage() { | ||||
| 			ctx.Data["IsImageFile"] = true | ||||
| 		} | ||||
|  | ||||
| @@ -322,13 +323,13 @@ func LFSFileGet(ctx *context.Context) { | ||||
| 		} | ||||
| 		ctx.Data["LineNums"] = gotemplate.HTML(output.String()) | ||||
|  | ||||
| 	case base.IsPDFFile(buf): | ||||
| 	case st.IsPDF(): | ||||
| 		ctx.Data["IsPDFFile"] = true | ||||
| 	case base.IsVideoFile(buf): | ||||
| 	case st.IsVideo(): | ||||
| 		ctx.Data["IsVideoFile"] = true | ||||
| 	case base.IsAudioFile(buf): | ||||
| 	case st.IsAudio(): | ||||
| 		ctx.Data["IsAudioFile"] = true | ||||
| 	case base.IsImageFile(buf): | ||||
| 	case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()): | ||||
| 		ctx.Data["IsImageFile"] = true | ||||
| 	} | ||||
| 	ctx.HTML(http.StatusOK, tplSettingsLFSFile) | ||||
|   | ||||
| @@ -24,6 +24,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/structs" | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
| 	"code.gitea.io/gitea/modules/typesniffer" | ||||
| 	"code.gitea.io/gitea/modules/validation" | ||||
| 	"code.gitea.io/gitea/modules/web" | ||||
| 	"code.gitea.io/gitea/routers/utils" | ||||
| @@ -1021,7 +1022,8 @@ func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error { | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("ioutil.ReadAll: %v", err) | ||||
| 	} | ||||
| 	if !base.IsImageFile(data) { | ||||
| 	st := typesniffer.DetectContentType(data) | ||||
| 	if !(st.IsImage() && !st.IsSvgImage()) { | ||||
| 		return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) | ||||
| 	} | ||||
| 	if err = ctxRepo.UploadAvatar(data); err != nil { | ||||
|   | ||||
| @@ -29,6 +29,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/markup" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/typesniffer" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| @@ -265,7 +266,9 @@ func renderDirectory(ctx *context.Context, treeLink string) { | ||||
| 		n, _ := dataRc.Read(buf) | ||||
| 		buf = buf[:n] | ||||
|  | ||||
| 		isTextFile := base.IsTextFile(buf) | ||||
| 		st := typesniffer.DetectContentType(buf) | ||||
| 		isTextFile := st.IsText() | ||||
|  | ||||
| 		ctx.Data["FileIsText"] = isTextFile | ||||
| 		ctx.Data["FileName"] = readmeFile.name | ||||
| 		fileSize := int64(0) | ||||
| @@ -302,7 +305,8 @@ func renderDirectory(ctx *context.Context, treeLink string) { | ||||
| 					} | ||||
| 					buf = buf[:n] | ||||
|  | ||||
| 					isTextFile = base.IsTextFile(buf) | ||||
| 					st = typesniffer.DetectContentType(buf) | ||||
| 					isTextFile = st.IsText() | ||||
| 					ctx.Data["IsTextFile"] = isTextFile | ||||
|  | ||||
| 					fileSize = meta.Size | ||||
| @@ -405,7 +409,9 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st | ||||
| 	n, _ := dataRc.Read(buf) | ||||
| 	buf = buf[:n] | ||||
|  | ||||
| 	isTextFile := base.IsTextFile(buf) | ||||
| 	st := typesniffer.DetectContentType(buf) | ||||
| 	isTextFile := st.IsText() | ||||
|  | ||||
| 	isLFSFile := false | ||||
| 	isDisplayingSource := ctx.Query("display") == "source" | ||||
| 	isDisplayingRendered := !isDisplayingSource | ||||
| @@ -441,14 +447,16 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st | ||||
| 				} | ||||
| 				buf = buf[:n] | ||||
|  | ||||
| 				isTextFile = base.IsTextFile(buf) | ||||
| 				st = typesniffer.DetectContentType(buf) | ||||
| 				isTextFile = st.IsText() | ||||
|  | ||||
| 				fileSize = meta.Size | ||||
| 				ctx.Data["RawFileLink"] = fmt.Sprintf("%s/media/%s/%s", ctx.Repo.RepoLink, ctx.Repo.BranchNameSubURL(), ctx.Repo.TreePath) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	isRepresentableAsText := base.IsRepresentableAsText(buf) | ||||
| 	isRepresentableAsText := st.IsRepresentableAsText() | ||||
| 	if !isRepresentableAsText { | ||||
| 		// If we can't show plain text, always try to render. | ||||
| 		isDisplayingSource = false | ||||
| @@ -483,8 +491,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st | ||||
|  | ||||
| 	switch { | ||||
| 	case isRepresentableAsText: | ||||
| 		// This will be true for SVGs. | ||||
| 		if base.IsImageFile(buf) { | ||||
| 		if st.IsSvgImage() { | ||||
| 			ctx.Data["IsImageFile"] = true | ||||
| 			ctx.Data["HasSourceRenderedToggle"] = true | ||||
| 		} | ||||
| @@ -540,13 +547,13 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 	case base.IsPDFFile(buf): | ||||
| 	case st.IsPDF(): | ||||
| 		ctx.Data["IsPDFFile"] = true | ||||
| 	case base.IsVideoFile(buf): | ||||
| 	case st.IsVideo(): | ||||
| 		ctx.Data["IsVideoFile"] = true | ||||
| 	case base.IsAudioFile(buf): | ||||
| 	case st.IsAudio(): | ||||
| 		ctx.Data["IsAudioFile"] = true | ||||
| 	case base.IsImageFile(buf): | ||||
| 	case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()): | ||||
| 		ctx.Data["IsImageFile"] = true | ||||
| 	default: | ||||
| 		if fileSize >= setting.UI.MaxDisplayFileSize { | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/context" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/typesniffer" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/modules/web" | ||||
| 	"code.gitea.io/gitea/modules/web/middleware" | ||||
| @@ -159,7 +160,9 @@ func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser * | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("ioutil.ReadAll: %v", err) | ||||
| 		} | ||||
| 		if !base.IsImageFile(data) { | ||||
|  | ||||
| 		st := typesniffer.DetectContentType(data) | ||||
| 		if !(st.IsImage() && !st.IsSvgImage()) { | ||||
| 			return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) | ||||
| 		} | ||||
| 		if err = ctxUser.UploadAvatar(data); err != nil { | ||||
|   | ||||
| @@ -29,10 +29,12 @@ | ||||
| 			{{range .Diff.Files}} | ||||
| 				<li> | ||||
| 					<div class="bold df ac pull-right"> | ||||
| 						{{if not .IsBin}} | ||||
| 							{{template "repo/diff/stats" dict "file" . "root" $}} | ||||
| 						{{if .IsBin}} | ||||
| 							<span class="ml-1 mr-3"> | ||||
| 								{{$.i18n.Tr "repo.diff.bin"}} | ||||
| 							</span> | ||||
| 						{{else}} | ||||
| 							<span>{{$.i18n.Tr "repo.diff.bin"}}</span> | ||||
| 							{{template "repo/diff/stats" dict "file" . "root" $}} | ||||
| 						{{end}} | ||||
| 					</div> | ||||
| 					<!-- todo finish all file status, now modify, add, delete and rename --> | ||||
| @@ -42,55 +44,22 @@ | ||||
| 			{{end}} | ||||
| 		</ol> | ||||
| 		{{range $i, $file := .Diff.Files}} | ||||
| 			{{if $file.IsIncomplete}} | ||||
| 				<div class="diff-file-box diff-box file-content mt-3"> | ||||
| 					<h4 class="ui top attached normal header rounded"> | ||||
| 						<a role="button" class="fold-file muted mr-2"> | ||||
| 							{{svg "octicon-chevron-down" 18}} | ||||
| 						</a> | ||||
| 						<div class="bold ui left df ac"> | ||||
| 							{{template "repo/diff/stats" dict "file" . "root" $}} | ||||
| 						</div> | ||||
| 						<span class="file mono">{{$file.Name}}</span> | ||||
| 						<div class="diff-file-header-actions df ac"> | ||||
| 							<div class="text grey"> | ||||
| 								{{if $file.IsIncompleteLineTooLong}} | ||||
| 									{{$.i18n.Tr "repo.diff.file_suppressed_line_too_long"}} | ||||
| 								{{else}} | ||||
| 									{{$.i18n.Tr "repo.diff.file_suppressed"}} | ||||
| 								{{end}} | ||||
| 							</div> | ||||
| 							{{if $file.IsProtected}} | ||||
| 								<span class="ui basic label">{{$.i18n.Tr "repo.diff.protected"}}</span> | ||||
| 							{{end}} | ||||
| 							{{if and (not $file.IsSubmodule) (not $.PageIsWiki)}} | ||||
| 								{{if $file.IsDeleted}} | ||||
| 									<a class="ui basic tiny button" rel="nofollow" href="{{EscapePound $.BeforeSourcePath}}/{{EscapePound .Name}}">{{$.i18n.Tr "repo.diff.view_file"}}</a> | ||||
| 								{{else}} | ||||
| 									<a class="ui basic tiny button" rel="nofollow" href="{{EscapePound $.SourcePath}}/{{EscapePound .Name}}">{{$.i18n.Tr "repo.diff.view_file"}}</a> | ||||
| 								{{end}} | ||||
| 							{{end}} | ||||
| 						</div> | ||||
| 					</h4> | ||||
| 				</div> | ||||
| 			{{else}} | ||||
| 			{{$blobBase := call $.GetBlobByPathForCommit $.BaseCommit $file.OldName}} | ||||
| 			{{$blobHead := call $.GetBlobByPathForCommit $.HeadCommit $file.Name}} | ||||
| 			{{$isImage := or (call $.IsBlobAnImage $blobBase) (call $.IsBlobAnImage $blobHead)}} | ||||
| 			{{$isCsv := (call $.IsCsvFile $file)}} | ||||
| 			{{$showFileViewToggle := or $isImage (and (not $file.IsIncomplete) $isCsv)}} | ||||
| 			<div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}} mt-3" id="diff-{{.Index}}"> | ||||
| 				<h4 class="diff-file-header sticky-2nd-row ui top attached normal header df ac sb"> | ||||
| 					<div class="df ac"> | ||||
| 							{{$isImage := false}} | ||||
| 							{{if $file.IsDeleted}} | ||||
| 								{{$isImage = (call $.IsImageFileInBase $file.Name)}} | ||||
| 							{{else}} | ||||
| 								{{$isImage = (call $.IsImageFileInHead $file.Name)}} | ||||
| 							{{end}} | ||||
| 							{{$isCsv := (call $.IsCsvFile $file)}} | ||||
| 							{{$showFileViewToggle := or $isImage $isCsv}} | ||||
| 						<a role="button" class="fold-file muted mr-2"> | ||||
| 							{{svg "octicon-chevron-down" 18}} | ||||
| 						</a> | ||||
| 						<div class="bold df ac"> | ||||
| 							{{if $file.IsBin}} | ||||
| 								<span class="ml-1 mr-3"> | ||||
| 									{{$.i18n.Tr "repo.diff.bin"}} | ||||
| 								</span> | ||||
| 							{{else}} | ||||
| 								{{template "repo/diff/stats" dict "file" . "root" $}} | ||||
| 							{{end}} | ||||
| @@ -100,8 +69,8 @@ | ||||
| 					<div class="diff-file-header-actions df ac"> | ||||
| 						{{if $showFileViewToggle}} | ||||
| 							<div class="ui compact icon buttons"> | ||||
| 									<span class="ui tiny basic button poping up active file-view-toggle" data-toggle-selector="#diff-source-{{$i}}" data-content="{{$.i18n.Tr "repo.file_view_source"}}" data-position="bottom center" data-variation="tiny inverted">{{svg "octicon-code"}}</span> | ||||
| 									<span class="ui tiny basic button poping up file-view-toggle" data-toggle-selector="#diff-rendered-{{$i}}" data-content="{{$.i18n.Tr "repo.file_view_rendered"}}" data-position="bottom center" data-variation="tiny inverted">{{svg "octicon-file"}}</span> | ||||
| 								<span class="ui tiny basic button poping up file-view-toggle" data-toggle-selector="#diff-source-{{$i}}" data-content="{{$.i18n.Tr "repo.file_view_source"}}" data-position="bottom center" data-variation="tiny inverted">{{svg "octicon-code"}}</span> | ||||
| 								<span class="ui tiny basic button poping up file-view-toggle active" data-toggle-selector="#diff-rendered-{{$i}}" data-content="{{$.i18n.Tr "repo.file_view_rendered"}}" data-position="bottom center" data-variation="tiny inverted">{{svg "octicon-file"}}</span> | ||||
| 							</div> | ||||
| 						{{end}} | ||||
| 						{{if $file.IsProtected}} | ||||
| @@ -117,9 +86,19 @@ | ||||
| 					</div> | ||||
| 				</h4> | ||||
| 				<div class="diff-file-body ui attached unstackable table segment"> | ||||
| 						<div id="diff-source-{{$i}}" class="file-body file-code code-diff{{if $.IsSplitStyle}} code-diff-split{{else}} code-diff-unified{{end}}"> | ||||
| 							{{if $file.IsBin}} | ||||
| 								<div class="diff-file-body binary" style="padding: 5px 10px;">{{$.i18n.Tr "repo.diff.bin_not_shown"}}</div> | ||||
| 					<div id="diff-source-{{$i}}" class="file-body file-code code-diff{{if $.IsSplitStyle}} code-diff-split{{else}} code-diff-unified{{end}}{{if $showFileViewToggle}} hide{{end}}"> | ||||
| 						{{if or $file.IsIncomplete $file.IsBin}} | ||||
| 							<div class="diff-file-body binary" style="padding: 5px 10px;"> | ||||
| 								{{if $file.IsIncomplete}} | ||||
| 									{{if $file.IsIncompleteLineTooLong}} | ||||
| 										{{$.i18n.Tr "repo.diff.file_suppressed_line_too_long"}} | ||||
| 									{{else}} | ||||
| 										{{$.i18n.Tr "repo.diff.file_suppressed"}} | ||||
| 									{{end}} | ||||
| 								{{else}} | ||||
| 									{{$.i18n.Tr "repo.diff.bin_not_shown"}} | ||||
| 								{{end}} | ||||
| 							</div> | ||||
| 						{{else}} | ||||
| 							<table class="chroma"> | ||||
| 								{{if $.IsSplitStyle}} | ||||
| @@ -130,11 +109,11 @@ | ||||
| 							</table> | ||||
| 						{{end}} | ||||
| 					</div> | ||||
| 						{{if or $isImage $isCsv}} | ||||
| 							<div id="diff-rendered-{{$i}}" class="file-body file-code {{if $.IsSplitStyle}} code-diff-split{{else}} code-diff-unified{{end}} hide"> | ||||
| 					{{if $showFileViewToggle}} | ||||
| 						<div id="diff-rendered-{{$i}}" class="file-body file-code {{if $.IsSplitStyle}} code-diff-split{{else}} code-diff-unified{{end}}"> | ||||
| 							<table class="chroma w-100"> | ||||
| 								{{if $isImage}} | ||||
| 										{{template "repo/diff/image_diff" dict "file" . "root" $}} | ||||
| 									{{template "repo/diff/image_diff" dict "file" . "root" $ "blobBase" $blobBase "blobHead" $blobHead}} | ||||
| 								{{else}} | ||||
| 									{{template "repo/diff/csv_diff" dict "file" . "root" $}} | ||||
| 								{{end}} | ||||
| @@ -144,7 +123,6 @@ | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		{{end}} | ||||
| 		{{end}} | ||||
|  | ||||
| 		{{if .Diff.IsIncomplete}} | ||||
| 			<div class="diff-file-box diff-box file-content mt-3"> | ||||
|   | ||||
| @@ -1,15 +1,13 @@ | ||||
| {{ $imagePathOld := printf "%s/%s" .root.BeforeRawPath (EscapePound .file.OldName)  }} | ||||
| {{ $imagePathNew := printf "%s/%s" .root.RawPath (EscapePound .file.Name)  }} | ||||
| {{ $imageInfoBase := (call .root.ImageInfoBase .file.OldName) }} | ||||
| {{ $imageInfoHead := (call .root.ImageInfo .file.Name) }} | ||||
| {{if or $imageInfoBase $imageInfoHead}} | ||||
| {{if or .blobBase .blobHead}} | ||||
| <tr> | ||||
| 	<td colspan="2"> | ||||
| 		<div class="image-diff" data-path-before="{{$imagePathOld}}" data-path-after="{{$imagePathNew}}"> | ||||
| 			<div class="ui secondary pointing tabular top attached borderless menu stackable new-menu"> | ||||
| 				<div class="new-menu-inner"> | ||||
| 					<a class="item active" data-tab="diff-side-by-side">{{.root.i18n.Tr "repo.diff.image.side_by_side"}}</a> | ||||
| 					{{if and $imageInfoBase $imageInfoHead}} | ||||
| 					{{if and .blobBase .blobHead}} | ||||
| 					<a class="item" data-tab="diff-swipe">{{.root.i18n.Tr "repo.diff.image.swipe"}}</a> | ||||
| 					<a class="item" data-tab="diff-overlay">{{.root.i18n.Tr "repo.diff.image.overlay"}}</a> | ||||
| 					{{end}} | ||||
| @@ -18,63 +16,39 @@ | ||||
| 			<div class="hide"> | ||||
| 				<div class="ui bottom attached tab image-diff-container active" data-tab="diff-side-by-side"> | ||||
| 					<div class="diff-side-by-side"> | ||||
| 						{{if $imageInfoBase }} | ||||
| 						{{if .blobBase }} | ||||
| 						<span class="side"> | ||||
| 							<p class="side-header">{{.root.i18n.Tr "repo.diff.file_before"}}</p> | ||||
| 							<span class="before-container"><img class="image-before" /></span> | ||||
| 							<p> | ||||
| 								{{ $classWidth := "" }} | ||||
| 								{{ $classHeight := "" }} | ||||
| 								{{ $classByteSize := "" }} | ||||
| 								{{if $imageInfoHead}} | ||||
| 									{{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}} | ||||
| 										{{ $classWidth = "red" }} | ||||
| 									{{end}} | ||||
| 									{{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}} | ||||
| 										{{ $classHeight = "red" }} | ||||
| 									{{end}} | ||||
| 									{{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}} | ||||
| 										{{ $classByteSize = "red" }} | ||||
| 									{{end}} | ||||
| 								{{end}} | ||||
| 								{{.root.i18n.Tr "repo.diff.file_image_width"}}: <span class="text {{$classWidth}}">{{$imageInfoBase.Width}}</span> | ||||
| 								<span class="bounds-info-before"> | ||||
| 									{{.root.i18n.Tr "repo.diff.file_image_width"}}: <span class="text bounds-info-width"></span> | ||||
| 									 |  | ||||
| 								{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text {{$classHeight}}">{{$imageInfoBase.Height}}</span> | ||||
| 									{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text bounds-info-height"></span> | ||||
| 									 |  | ||||
| 								{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text {{$classByteSize}}">{{FileSize $imageInfoBase.ByteSize}}</span> | ||||
| 								</span> | ||||
| 								{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text">{{FileSize .blobBase.Size}}</span> | ||||
| 							</p> | ||||
| 						</span> | ||||
| 						{{end}} | ||||
| 						{{if $imageInfoHead }} | ||||
| 						{{if .blobHead }} | ||||
| 						<span class="side"> | ||||
| 							<p class="side-header">{{.root.i18n.Tr "repo.diff.file_after"}}</p> | ||||
| 							<span class="after-container"><img class="image-after" /></span> | ||||
| 							<p> | ||||
| 								{{ $classWidth := "" }} | ||||
| 								{{ $classHeight := "" }} | ||||
| 								{{ $classByteSize := "" }} | ||||
| 								{{if $imageInfoBase}} | ||||
| 									{{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}} | ||||
| 										{{ $classWidth = "green" }} | ||||
| 									{{end}} | ||||
| 									{{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}} | ||||
| 										{{ $classHeight = "green" }} | ||||
| 									{{end}} | ||||
| 									{{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}} | ||||
| 										{{ $classByteSize = "green" }} | ||||
| 									{{end}} | ||||
| 								{{end}} | ||||
| 								{{.root.i18n.Tr "repo.diff.file_image_width"}}: <span class="text {{$classWidth}}">{{$imageInfoHead.Width}}</span> | ||||
| 								<span class="bounds-info-after"> | ||||
| 									{{.root.i18n.Tr "repo.diff.file_image_width"}}: <span class="text bounds-info-width"></span> | ||||
| 									 |  | ||||
| 								{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text {{$classHeight}}">{{$imageInfoHead.Height}}</span> | ||||
| 									{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text bounds-info-height"></span> | ||||
| 									 |  | ||||
| 								{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text {{$classByteSize}}">{{FileSize $imageInfoHead.ByteSize}}</span> | ||||
| 								</span> | ||||
| 								{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text">{{FileSize .blobHead.Size}}</span> | ||||
| 							</p> | ||||
| 						</span> | ||||
| 						{{end}} | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				{{if and $imageInfoBase $imageInfoHead}} | ||||
| 				{{if and .blobBase .blobHead}} | ||||
| 				<div class="ui bottom attached tab image-diff-container" data-tab="diff-swipe"> | ||||
| 					<div class="diff-swipe"> | ||||
| 						<div class="swipe-frame"> | ||||
| @@ -102,7 +76,7 @@ | ||||
| 				</div> | ||||
| 				{{end}} | ||||
| 			</div> | ||||
| 			<div class="ui active centered inline loader"></div> | ||||
| 			<div class="ui active centered inline loader mb-4"></div> | ||||
| 		</div> | ||||
| 	</td> | ||||
| </tr> | ||||
|   | ||||
| @@ -1,3 +1,34 @@ | ||||
| function getDefaultSvgBoundsIfUndefined(svgXml, src) { | ||||
|   const DefaultSize = 300; | ||||
|   const MaxSize = 99999; | ||||
|  | ||||
|   const svg = svgXml.rootElement; | ||||
|  | ||||
|   const width = svg.width.baseVal; | ||||
|   const height = svg.height.baseVal; | ||||
|   if (width.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE || height.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE) { | ||||
|     const img = new Image(); | ||||
|     img.src = src; | ||||
|     if (img.width > 1 && img.width < MaxSize && img.height > 1 && img.height < MaxSize) { | ||||
|       return { | ||||
|         width: img.width, | ||||
|         height: img.height | ||||
|       }; | ||||
|     } | ||||
|     if (svg.hasAttribute('viewBox')) { | ||||
|       const viewBox = svg.viewBox.baseVal; | ||||
|       return { | ||||
|         width: DefaultSize, | ||||
|         height: DefaultSize * viewBox.width / viewBox.height | ||||
|       }; | ||||
|     } | ||||
|     return { | ||||
|       width: DefaultSize, | ||||
|       height: DefaultSize | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default async function initImageDiff() { | ||||
|   function createContext(image1, image2) { | ||||
|     const size1 = { | ||||
| @@ -30,34 +61,50 @@ export default async function initImageDiff() { | ||||
|  | ||||
|   $('.image-diff').each(function() { | ||||
|     const $container = $(this); | ||||
|  | ||||
|     const diffContainerWidth = $container.width() - 300; | ||||
|     const pathAfter = $container.data('path-after'); | ||||
|     const pathBefore = $container.data('path-before'); | ||||
|  | ||||
|     const imageInfos = [{ | ||||
|       loaded: false, | ||||
|       path: pathAfter, | ||||
|       $image: $container.find('img.image-after') | ||||
|       $image: $container.find('img.image-after'), | ||||
|       $boundsInfo: $container.find('.bounds-info-after') | ||||
|     }, { | ||||
|       loaded: false, | ||||
|       path: pathBefore, | ||||
|       $image: $container.find('img.image-before') | ||||
|       $image: $container.find('img.image-before'), | ||||
|       $boundsInfo: $container.find('.bounds-info-before') | ||||
|     }]; | ||||
|  | ||||
|     for (const info of imageInfos) { | ||||
|       if (info.$image.length > 0) { | ||||
|         $.ajax({ | ||||
|           url: info.path, | ||||
|           success: (data, _, jqXHR) => { | ||||
|             info.$image.on('load', () => { | ||||
|               info.loaded = true; | ||||
|               setReadyIfLoaded(); | ||||
|             }); | ||||
|             info.$image.attr('src', info.path); | ||||
|  | ||||
|             if (jqXHR.getResponseHeader('Content-Type') === 'image/svg+xml') { | ||||
|               const bounds = getDefaultSvgBoundsIfUndefined(data, info.path); | ||||
|               if (bounds) { | ||||
|                 info.$image.attr('width', bounds.width); | ||||
|                 info.$image.attr('height', bounds.height); | ||||
|                 info.$boundsInfo.hide(); | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }); | ||||
|       } else { | ||||
|         info.loaded = true; | ||||
|         setReadyIfLoaded(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const diffContainerWidth = $container.width() - 300; | ||||
|  | ||||
|     function setReadyIfLoaded() { | ||||
|       if (imageInfos[0].loaded && imageInfos[1].loaded) { | ||||
|         initViews(imageInfos[0].$image, imageInfos[1].$image); | ||||
| @@ -81,6 +128,17 @@ export default async function initImageDiff() { | ||||
|         factor = (diffContainerWidth - 24) / 2 / sizes.max.width; | ||||
|       } | ||||
|  | ||||
|       const widthChanged = sizes.image1.length !== 0 && sizes.image2.length !== 0 && sizes.image1[0].naturalWidth !== sizes.image2[0].naturalWidth; | ||||
|       const heightChanged = sizes.image1.length !== 0 && sizes.image2.length !== 0 && sizes.image1[0].naturalHeight !== sizes.image2[0].naturalHeight; | ||||
|       if (sizes.image1.length !== 0) { | ||||
|         $container.find('.bounds-info-after .bounds-info-width').text(`${sizes.image1[0].naturalWidth}px`).addClass(widthChanged ? 'green' : ''); | ||||
|         $container.find('.bounds-info-after .bounds-info-height').text(`${sizes.image1[0].naturalHeight}px`).addClass(heightChanged ? 'green' : ''); | ||||
|       } | ||||
|       if (sizes.image2.length !== 0) { | ||||
|         $container.find('.bounds-info-before .bounds-info-width').text(`${sizes.image2[0].naturalWidth}px`).addClass(widthChanged ? 'red' : ''); | ||||
|         $container.find('.bounds-info-before .bounds-info-height').text(`${sizes.image2[0].naturalHeight}px`).addClass(heightChanged ? 'red' : ''); | ||||
|       } | ||||
|  | ||||
|       sizes.image1.css({ | ||||
|         width: sizes.size1.width * factor, | ||||
|         height: sizes.size1.height * factor | ||||
|   | ||||
		Reference in New Issue
	
	Block a user