* 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>tags/v1.15.0-rc1
@@ -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 | |||
} |
@@ -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 | |||
} |
@@ -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 | |||
} | |||
result, err := base.ImageInfo(name) | |||
if err != nil { | |||
log.Error("ImageInfo failed: %v", err) | |||
return nil | |||
func setImageCompareContext(ctx *context.Context) { | |||
ctx.Data["IsBlobAnImage"] = func(blob *git.Blob) bool { | |||
if blob == nil { | |||
return false | |||
} | |||
return result | |||
} | |||
ctx.Data["ImageInfo"] = func(name string) *git.ImageMetaData { | |||
result, err := head.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 | |||
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,28 +45,32 @@ 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)) | |||
ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") | |||
if base.IsSVGImageFile(buf) { | |||
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) | |||
} | |||
} 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 { | |||
ctx.Resp.Header().Set("Content-Type", mimetype) | |||
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", typesniffer.SvgMimeType) | |||
} | |||
} else { | |||
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name)) | |||
if setting.MimeTypeMap.Enabled { | |||
fileExtension := strings.ToLower(filepath.Ext(name)) | |||
if mimetype, ok := setting.MimeTypeMap.Map[fileExtension]; ok { | |||
ctx.Resp.Header().Set("Content-Type", mimetype) | |||
} | |||
} | |||
} | |||
} |
@@ -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,108 +44,84 @@ | |||
{{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"> | |||
{{$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"> | |||
<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}} | |||
<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}} | |||
</div> | |||
</h4> | |||
</div> | |||
{{else}} | |||
<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}} | |||
<span class="file mono">{{if $file.IsRenamed}}{{$file.OldName}} → {{end}}{{$file.Name}}{{if .IsLFSFile}} ({{$.i18n.Tr "repo.stored_lfs"}}){{end}}</span> | |||
</div> | |||
<div class="diff-file-header-actions df ac"> | |||
{{if $showFileViewToggle}} | |||
<div class="ui compact icon buttons"> | |||
<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}} | |||
<span class="ui basic label">{{$.i18n.Tr "repo.diff.protected"}}</span> | |||
{{end}} | |||
{{if and (not $file.IsSubmodule) (not $.PageIsWiki)}} | |||
{{if $file.IsDeleted}} | |||
{{$isImage = (call $.IsImageFileInBase $file.Name)}} | |||
<a class="ui basic tiny button" rel="nofollow" href="{{EscapePound $.BeforeSourcePath}}/{{EscapePound .Name}}">{{$.i18n.Tr "repo.diff.view_file"}}</a> | |||
{{else}} | |||
{{$isImage = (call $.IsImageFileInHead $file.Name)}} | |||
<a class="ui basic tiny button" rel="nofollow" href="{{EscapePound $.SourcePath}}/{{EscapePound .Name}}">{{$.i18n.Tr "repo.diff.view_file"}}</a> | |||
{{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}} | |||
{{$.i18n.Tr "repo.diff.bin"}} | |||
{{end}} | |||
</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 $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}} | |||
{{template "repo/diff/stats" dict "file" . "root" $}} | |||
{{$.i18n.Tr "repo.diff.bin_not_shown"}} | |||
{{end}} | |||
</div> | |||
<span class="file mono">{{if $file.IsRenamed}}{{$file.OldName}} → {{end}}{{$file.Name}}{{if .IsLFSFile}} ({{$.i18n.Tr "repo.stored_lfs"}}){{end}}</span> | |||
</div> | |||
<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> | |||
</div> | |||
{{end}} | |||
{{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}} | |||
<table class="chroma"> | |||
{{if $.IsSplitStyle}} | |||
{{template "repo/diff/section_split" dict "file" . "root" $}} | |||
{{else}} | |||
<a class="ui basic tiny button" rel="nofollow" href="{{EscapePound $.SourcePath}}/{{EscapePound .Name}}">{{$.i18n.Tr "repo.diff.view_file"}}</a> | |||
{{template "repo/diff/section_unified" dict "file" . "root" $}} | |||
{{end}} | |||
{{end}} | |||
</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> | |||
{{else}} | |||
<table class="chroma"> | |||
{{if $.IsSplitStyle}} | |||
{{template "repo/diff/section_split" dict "file" . "root" $}} | |||
{{else}} | |||
{{template "repo/diff/section_unified" dict "file" . "root" $}} | |||
{{end}} | |||
</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"> | |||
<table class="chroma w-100"> | |||
{{if $isImage}} | |||
{{template "repo/diff/image_diff" dict "file" . "root" $}} | |||
{{else}} | |||
{{template "repo/diff/csv_diff" dict "file" . "root" $}} | |||
{{end}} | |||
</table> | |||
</div> | |||
</table> | |||
{{end}} | |||
</div> | |||
{{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" $ "blobBase" $blobBase "blobHead" $blobHead}} | |||
{{else}} | |||
{{template "repo/diff/csv_diff" dict "file" . "root" $}} | |||
{{end}} | |||
</table> | |||
</div> | |||
{{end}} | |||
</div> | |||
{{end}} | |||
</div> | |||
{{end}} | |||
{{if .Diff.IsIncomplete}} |
@@ -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> | |||
| | |||
{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text {{$classHeight}}">{{$imageInfoBase.Height}}</span> | |||
| | |||
{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text {{$classByteSize}}">{{FileSize $imageInfoBase.ByteSize}}</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 bounds-info-height"></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> | |||
| | |||
{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text {{$classHeight}}">{{$imageInfoHead.Height}}</span> | |||
| | |||
{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text {{$classByteSize}}">{{FileSize $imageInfoHead.ByteSize}}</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 bounds-info-height"></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) { | |||
info.$image.on('load', () => { | |||
info.loaded = true; | |||
setReadyIfLoaded(); | |||
$.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(); | |||
} | |||
} | |||
} | |||
}); | |||
info.$image.attr('src', info.path); | |||
} 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 |