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>
tokarchuk/v1.17
KN4CK3R 4 years ago committed by GitHub
parent 7979c3654e
commit 8e262104c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      modules/avatar/avatar.go
  2. 68
      modules/base/tool.go
  3. 92
      modules/base/tool_test.go
  4. 13
      modules/git/blob.go
  5. 70
      modules/git/commit.go
  6. 4
      modules/indexer/code/bleve.go
  7. 4
      modules/indexer/code/elastic_search.go
  8. 96
      modules/typesniffer/typesniffer.go
  9. 97
      modules/typesniffer/typesniffer_test.go
  10. 41
      routers/repo/compare.go
  11. 34
      routers/repo/download.go
  12. 5
      routers/repo/editor.go
  13. 17
      routers/repo/lfs.go
  14. 4
      routers/repo/setting.go
  15. 29
      routers/repo/view.go
  16. 5
      routers/user/setting/profile.go
  17. 156
      templates/repo/diff/box.tmpl
  18. 66
      templates/repo/diff/image_diff.tmpl
  19. 74
      web_src/js/features/imagediff.js

@ -10,8 +10,9 @@ import (
"image" "image"
"image/color/palette" "image/color/palette"
// Enable PNG support: _ "image/gif" // for processing gif images
_ "image/png" _ "image/jpeg" // for processing jpeg images
_ "image/png" // for processing png images
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"

@ -12,10 +12,8 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
@ -30,15 +28,6 @@ import (
"github.com/dustin/go-humanize" "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. // EncodeMD5 encodes string to md5 hex value.
func EncodeMD5(str string) string { func EncodeMD5(str string) string {
m := md5.New() 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) 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 // EntryIcon returns the octicon class for displaying files/directories
func EntryIcon(entry *git.TreeEntry) string { func EntryIcon(entry *git.TreeEntry) string {
switch { switch {

@ -5,7 +5,6 @@
package base package base
import ( import (
"encoding/base64"
"os" "os"
"testing" "testing"
"time" "time"
@ -246,97 +245,6 @@ func TestIsLetter(t *testing.T) {
assert.False(t, IsLetter(0x93)) 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 // TODO: Test EntryIcon
func TestSetupGiteaRoot(t *testing.T) { func TestSetupGiteaRoot(t *testing.T) {

@ -10,6 +10,8 @@ import (
"encoding/base64" "encoding/base64"
"io" "io"
"io/ioutil" "io/ioutil"
"code.gitea.io/gitea/modules/typesniffer"
) )
// This file contains common functions between the gogit and !gogit variants for git Blobs // 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 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" "container/list"
"errors" "errors"
"fmt" "fmt"
"image"
"image/color"
_ "image/gif" // for processing gif images
_ "image/jpeg" // for processing jpeg images
_ "image/png" // for processing png images
"io" "io"
"net/http"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
@ -81,70 +75,6 @@ func (c *Commit) ParentCount() int {
return len(c.Parents) 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. // GetCommitByPath return the commit of relative path object.
func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) { func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) {
return c.repo.getCommitByPathWithID(c.ID, relpath) return c.repo.getCommitByPathWithID(c.ID, relpath)

@ -16,12 +16,12 @@ import (
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/analyze" "code.gitea.io/gitea/modules/analyze"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"github.com/blevesearch/bleve/v2" "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)) fileContents, err := ioutil.ReadAll(io.LimitReader(batchReader, size))
if err != nil { if err != nil {
return err return err
} else if !base.IsTextFile(fileContents) { } else if !typesniffer.DetectContentType(fileContents).IsText() {
// FIXME: UTF-16 files will probably fail here // FIXME: UTF-16 files will probably fail here
return nil return nil
} }

@ -16,12 +16,12 @@ import (
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/analyze" "code.gitea.io/gitea/modules/analyze"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/typesniffer"
"github.com/go-enry/go-enry/v2" "github.com/go-enry/go-enry/v2"
jsoniter "github.com/json-iterator/go" 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)) fileContents, err := ioutil.ReadAll(io.LimitReader(batchReader, size))
if err != nil { if err != nil {
return nil, err return nil, err
} else if !base.IsTextFile(fileContents) { } else if !typesniffer.DetectContentType(fileContents).IsText() {
// FIXME: UTF-16 files will probably fail here // FIXME: UTF-16 files will probably fail here
return nil, nil 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["BaseCommit"] = base
ctx.Data["HeadCommit"] = head 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) setPathsCompareContext(ctx, base, head, headTarget)
setImageCompareContext(ctx, base, head) setImageCompareContext(ctx)
setCsvCompareContext(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 // setImageCompareContext sets context data that is required by image compare template
func setImageCompareContext(ctx *context.Context, base *git.Commit, head *git.Commit) { func setImageCompareContext(ctx *context.Context) {
ctx.Data["IsImageFileInHead"] = head.IsImageFile ctx.Data["IsBlobAnImage"] = func(blob *git.Blob) bool {
ctx.Data["IsImageFileInBase"] = base.IsImageFile if blob == nil {
ctx.Data["ImageInfoBase"] = func(name string) *git.ImageMetaData { return false
if base == nil {
return nil
}
result, err := base.ImageInfo(name)
if err != nil {
log.Error("ImageInfo failed: %v", err)
return nil
} }
return result
} st, err := blob.GuessContentType()
ctx.Data["ImageInfo"] = func(name string) *git.ImageMetaData {
result, err := head.ImageInfo(name)
if err != nil { if err != nil {
log.Error("ImageInfo failed: %v", err) log.Error("GuessContentType failed: %v", err)
return nil return false
} }
return result return st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage())
} }
} }

@ -12,7 +12,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
@ -20,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
) )
// ServeData download file from io.Reader // 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 // Google Chrome dislike commas in filenames, so let's change it to a space
name = strings.ReplaceAll(name, ",", " ") 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) cs, err := charset.DetectEncoding(buf)
if err != nil { if err != nil {
log.Error("Detect raw file %s charset failed: %v, using by default utf-8", name, err) log.Error("Detect raw file %s charset failed: %v, using by default utf-8", name, err)
cs = "utf-8" cs = "utf-8"
} }
ctx.Resp.Header().Set("Content-Type", "text/plain; charset="+strings.ToLower(cs)) 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 { } else {
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
if setting.MimeTypeMap.Enabled {
fileExtension := strings.ToLower(filepath.Ext(name)) if (st.IsImage() || st.IsPDF()) && (setting.UI.SVG.Enabled || !st.IsSvgImage()) {
if mimetype, ok := setting.MimeTypeMap.Map[fileExtension]; ok { ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name))
ctx.Resp.Header().Set("Content-Type", mimetype) 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" "code.gitea.io/gitea/modules/repofiles"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/upload"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
@ -117,8 +118,8 @@ func editFile(ctx *context.Context, isNewFile bool) {
buf = buf[:n] buf = buf[:n]
// Only some file types are editable online as text. // Only some file types are editable online as text.
if !base.IsRepresentableAsText(buf) { if !typesniffer.DetectContentType(buf).IsRepresentableAsText() {
ctx.NotFound("base.IsRepresentableAsText", nil) ctx.NotFound("typesniffer.IsRepresentableAsText", nil)
return return
} }

@ -25,6 +25,7 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/typesniffer"
) )
const ( const (
@ -278,16 +279,16 @@ func LFSFileGet(ctx *context.Context) {
} }
buf = buf[:n] buf = buf[:n]
ctx.Data["IsTextFile"] = base.IsTextFile(buf) st := typesniffer.DetectContentType(buf)
isRepresentableAsText := base.IsRepresentableAsText(buf) ctx.Data["IsTextFile"] = st.IsText()
isRepresentableAsText := st.IsRepresentableAsText()
fileSize := meta.Size fileSize := meta.Size
ctx.Data["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") ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s.git/info/lfs/objects/%s/%s", setting.AppURL, ctx.Repo.Repository.FullName(), meta.Oid, "direct")
switch { switch {
case isRepresentableAsText: case isRepresentableAsText:
// This will be true for SVGs. if st.IsSvgImage() {
if base.IsImageFile(buf) {
ctx.Data["IsImageFile"] = true ctx.Data["IsImageFile"] = true
} }
@ -322,13 +323,13 @@ func LFSFileGet(ctx *context.Context) {
} }
ctx.Data["LineNums"] = gotemplate.HTML(output.String()) ctx.Data["LineNums"] = gotemplate.HTML(output.String())
case base.IsPDFFile(buf): case st.IsPDF():
ctx.Data["IsPDFFile"] = true ctx.Data["IsPDFFile"] = true
case base.IsVideoFile(buf): case st.IsVideo():
ctx.Data["IsVideoFile"] = true ctx.Data["IsVideoFile"] = true
case base.IsAudioFile(buf): case st.IsAudio():
ctx.Data["IsAudioFile"] = true ctx.Data["IsAudioFile"] = true
case base.IsImageFile(buf): case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()):
ctx.Data["IsImageFile"] = true ctx.Data["IsImageFile"] = true
} }
ctx.HTML(http.StatusOK, tplSettingsLFSFile) ctx.HTML(http.StatusOK, tplSettingsLFSFile)

@ -24,6 +24,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/validation"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/utils" "code.gitea.io/gitea/routers/utils"
@ -1021,7 +1022,8 @@ func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error {
if err != nil { if err != nil {
return fmt.Errorf("ioutil.ReadAll: %v", err) 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")) return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image"))
} }
if err = ctxRepo.UploadAvatar(data); err != nil { if err = ctxRepo.UploadAvatar(data); err != nil {

@ -29,6 +29,7 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
) )
const ( const (
@ -265,7 +266,9 @@ func renderDirectory(ctx *context.Context, treeLink string) {
n, _ := dataRc.Read(buf) n, _ := dataRc.Read(buf)
buf = buf[:n] buf = buf[:n]
isTextFile := base.IsTextFile(buf) st := typesniffer.DetectContentType(buf)
isTextFile := st.IsText()
ctx.Data["FileIsText"] = isTextFile ctx.Data["FileIsText"] = isTextFile
ctx.Data["FileName"] = readmeFile.name ctx.Data["FileName"] = readmeFile.name
fileSize := int64(0) fileSize := int64(0)
@ -302,7 +305,8 @@ func renderDirectory(ctx *context.Context, treeLink string) {
} }
buf = buf[:n] buf = buf[:n]
isTextFile = base.IsTextFile(buf) st = typesniffer.DetectContentType(buf)
isTextFile = st.IsText()
ctx.Data["IsTextFile"] = isTextFile ctx.Data["IsTextFile"] = isTextFile
fileSize = meta.Size fileSize = meta.Size
@ -405,7 +409,9 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
n, _ := dataRc.Read(buf) n, _ := dataRc.Read(buf)
buf = buf[:n] buf = buf[:n]
isTextFile := base.IsTextFile(buf) st := typesniffer.DetectContentType(buf)
isTextFile := st.IsText()
isLFSFile := false isLFSFile := false
isDisplayingSource := ctx.Query("display") == "source" isDisplayingSource := ctx.Query("display") == "source"
isDisplayingRendered := !isDisplayingSource isDisplayingRendered := !isDisplayingSource
@ -441,14 +447,16 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
} }
buf = buf[:n] buf = buf[:n]
isTextFile = base.IsTextFile(buf) st = typesniffer.DetectContentType(buf)
isTextFile = st.IsText()
fileSize = meta.Size fileSize = meta.Size
ctx.Data["RawFileLink"] = fmt.Sprintf("%s/media/%s/%s", ctx.Repo.RepoLink, ctx.Repo.BranchNameSubURL(), ctx.Repo.TreePath) 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 !isRepresentableAsText {
// If we can't show plain text, always try to render. // If we can't show plain text, always try to render.
isDisplayingSource = false isDisplayingSource = false
@ -483,8 +491,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
switch { switch {
case isRepresentableAsText: case isRepresentableAsText:
// This will be true for SVGs. if st.IsSvgImage() {
if base.IsImageFile(buf) {
ctx.Data["IsImageFile"] = true ctx.Data["IsImageFile"] = true
ctx.Data["HasSourceRenderedToggle"] = 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 ctx.Data["IsPDFFile"] = true
case base.IsVideoFile(buf): case st.IsVideo():
ctx.Data["IsVideoFile"] = true ctx.Data["IsVideoFile"] = true
case base.IsAudioFile(buf): case st.IsAudio():
ctx.Data["IsAudioFile"] = true ctx.Data["IsAudioFile"] = true
case base.IsImageFile(buf): case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()):
ctx.Data["IsImageFile"] = true ctx.Data["IsImageFile"] = true
default: default:
if fileSize >= setting.UI.MaxDisplayFileSize { if fileSize >= setting.UI.MaxDisplayFileSize {

@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
@ -159,7 +160,9 @@ func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *
if err != nil { if err != nil {
return fmt.Errorf("ioutil.ReadAll: %v", err) 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")) return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image"))
} }
if err = ctxUser.UploadAvatar(data); err != nil { if err = ctxUser.UploadAvatar(data); err != nil {

@ -29,10 +29,12 @@
{{range .Diff.Files}} {{range .Diff.Files}}
<li> <li>
<div class="bold df ac pull-right"> <div class="bold df ac pull-right">
{{if not .IsBin}} {{if .IsBin}}
{{template "repo/diff/stats" dict "file" . "root" $}} <span class="ml-1 mr-3">
{{$.i18n.Tr "repo.diff.bin"}}
</span>
{{else}} {{else}}
<span>{{$.i18n.Tr "repo.diff.bin"}}</span> {{template "repo/diff/stats" dict "file" . "root" $}}
{{end}} {{end}}
</div> </div>
<!-- todo finish all file status, now modify, add, delete and rename --> <!-- todo finish all file status, now modify, add, delete and rename -->
@ -42,108 +44,84 @@
{{end}} {{end}}
</ol> </ol>
{{range $i, $file := .Diff.Files}} {{range $i, $file := .Diff.Files}}
{{if $file.IsIncomplete}} {{$blobBase := call $.GetBlobByPathForCommit $.BaseCommit $file.OldName}}
<div class="diff-file-box diff-box file-content mt-3"> {{$blobHead := call $.GetBlobByPathForCommit $.HeadCommit $file.Name}}
<h4 class="ui top attached normal header rounded"> {{$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"> <a role="button" class="fold-file muted mr-2">
{{svg "octicon-chevron-down" 18}} {{svg "octicon-chevron-down" 18}}
</a> </a>
<div class="bold ui left df ac"> <div class="bold df ac">
{{template "repo/diff/stats" dict "file" . "root" $}} {{if $file.IsBin}}
</div> <span class="ml-1 mr-3">
<span class="file mono">{{$file.Name}}</span> {{$.i18n.Tr "repo.diff.bin"}}
<div class="diff-file-header-actions df ac"> </span>
<div class="text grey"> {{else}}
{{if $file.IsIncompleteLineTooLong}} {{template "repo/diff/stats" dict "file" . "root" $}}
{{$.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}} {{end}}
</div> </div>
</h4> <span class="file mono">{{if $file.IsRenamed}}{{$file.OldName}} &rarr; {{end}}{{$file.Name}}{{if .IsLFSFile}} ({{$.i18n.Tr "repo.stored_lfs"}}){{end}}</span>
</div> </div>
{{else}} <div class="diff-file-header-actions df ac">
<div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}} mt-3" id="diff-{{.Index}}"> {{if $showFileViewToggle}}
<h4 class="diff-file-header sticky-2nd-row ui top attached normal header df ac sb"> <div class="ui compact icon buttons">
<div class="df ac"> <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>
{{$isImage := false}} <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}} {{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}} {{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}} {{end}}
{{$isCsv := (call $.IsCsvFile $file)}} {{end}}
{{$showFileViewToggle := or $isImage $isCsv}} </div>
<a role="button" class="fold-file muted mr-2"> </h4>
{{svg "octicon-chevron-down" 18}} <div class="diff-file-body ui attached unstackable table segment">
</a> <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}}">
<div class="bold df ac"> {{if or $file.IsIncomplete $file.IsBin}}
{{if $file.IsBin}} <div class="diff-file-body binary" style="padding: 5px 10px;">
{{$.i18n.Tr "repo.diff.bin"}} {{if $file.IsIncomplete}}
{{if $file.IsIncompleteLineTooLong}}
{{$.i18n.Tr "repo.diff.file_suppressed_line_too_long"}}
{{else}}
{{$.i18n.Tr "repo.diff.file_suppressed"}}
{{end}}
{{else}} {{else}}
{{template "repo/diff/stats" dict "file" . "root" $}} {{$.i18n.Tr "repo.diff.bin_not_shown"}}
{{end}} {{end}}
</div> </div>
<span class="file mono">{{if $file.IsRenamed}}{{$file.OldName}} &rarr; {{end}}{{$file.Name}}{{if .IsLFSFile}} ({{$.i18n.Tr "repo.stored_lfs"}}){{end}}</span> {{else}}
</div> <table class="chroma">
<div class="diff-file-header-actions df ac"> {{if $.IsSplitStyle}}
{{if $showFileViewToggle}} {{template "repo/diff/section_split" dict "file" . "root" $}}
<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}} {{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}}
{{end}} </table>
</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>
{{end}} {{end}}
</div> </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> </div>
{{end}} </div>
{{end}} {{end}}
{{if .Diff.IsIncomplete}} {{if .Diff.IsIncomplete}}

@ -1,15 +1,13 @@
{{ $imagePathOld := printf "%s/%s" .root.BeforeRawPath (EscapePound .file.OldName) }} {{ $imagePathOld := printf "%s/%s" .root.BeforeRawPath (EscapePound .file.OldName) }}
{{ $imagePathNew := printf "%s/%s" .root.RawPath (EscapePound .file.Name) }} {{ $imagePathNew := printf "%s/%s" .root.RawPath (EscapePound .file.Name) }}
{{ $imageInfoBase := (call .root.ImageInfoBase .file.OldName) }} {{if or .blobBase .blobHead}}
{{ $imageInfoHead := (call .root.ImageInfo .file.Name) }}
{{if or $imageInfoBase $imageInfoHead}}
<tr> <tr>
<td colspan="2"> <td colspan="2">
<div class="image-diff" data-path-before="{{$imagePathOld}}" data-path-after="{{$imagePathNew}}"> <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="ui secondary pointing tabular top attached borderless menu stackable new-menu">
<div class="new-menu-inner"> <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> <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-swipe">{{.root.i18n.Tr "repo.diff.image.swipe"}}</a>
<a class="item" data-tab="diff-overlay">{{.root.i18n.Tr "repo.diff.image.overlay"}}</a> <a class="item" data-tab="diff-overlay">{{.root.i18n.Tr "repo.diff.image.overlay"}}</a>
{{end}} {{end}}
@ -18,63 +16,39 @@
<div class="hide"> <div class="hide">
<div class="ui bottom attached tab image-diff-container active" data-tab="diff-side-by-side"> <div class="ui bottom attached tab image-diff-container active" data-tab="diff-side-by-side">
<div class="diff-side-by-side"> <div class="diff-side-by-side">
{{if $imageInfoBase }} {{if .blobBase }}
<span class="side"> <span class="side">
<p class="side-header">{{.root.i18n.Tr "repo.diff.file_before"}}</p> <p class="side-header">{{.root.i18n.Tr "repo.diff.file_before"}}</p>
<span class="before-container"><img class="image-before" /></span> <span class="before-container"><img class="image-before" /></span>
<p> <p>
{{ $classWidth := "" }} <span class="bounds-info-before">
{{ $classHeight := "" }} {{.root.i18n.Tr "repo.diff.file_image_width"}}: <span class="text bounds-info-width"></span>
{{ $classByteSize := "" }} &nbsp;|&nbsp;
{{if $imageInfoHead}} {{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text bounds-info-height"></span>
{{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}} &nbsp;|&nbsp;
{{ $classWidth = "red" }} </span>
{{end}} {{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text">{{FileSize .blobBase.Size}}</span>
{{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>
&nbsp;|&nbsp;
{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text {{$classHeight}}">{{$imageInfoBase.Height}}</span>
&nbsp;|&nbsp;
{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text {{$classByteSize}}">{{FileSize $imageInfoBase.ByteSize}}</span>
</p> </p>
</span> </span>
{{end}} {{end}}
{{if $imageInfoHead }} {{if .blobHead }}
<span class="side"> <span class="side">
<p class="side-header">{{.root.i18n.Tr "repo.diff.file_after"}}</p> <p class="side-header">{{.root.i18n.Tr "repo.diff.file_after"}}</p>
<span class="after-container"><img class="image-after" /></span> <span class="after-container"><img class="image-after" /></span>
<p> <p>
{{ $classWidth := "" }} <span class="bounds-info-after">
{{ $classHeight := "" }} {{.root.i18n.Tr "repo.diff.file_image_width"}}: <span class="text bounds-info-width"></span>
{{ $classByteSize := "" }} &nbsp;|&nbsp;
{{if $imageInfoBase}} {{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text bounds-info-height"></span>
{{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}} &nbsp;|&nbsp;
{{ $classWidth = "green" }} </span>
{{end}} {{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text">{{FileSize .blobHead.Size}}</span>
{{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>
&nbsp;|&nbsp;
{{.root.i18n.Tr "repo.diff.file_image_height"}}: <span class="text {{$classHeight}}">{{$imageInfoHead.Height}}</span>
&nbsp;|&nbsp;
{{.root.i18n.Tr "repo.diff.file_byte_size"}}: <span class="text {{$classByteSize}}">{{FileSize $imageInfoHead.ByteSize}}</span>
</p> </p>
</span> </span>
{{end}} {{end}}
</div> </div>
</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="ui bottom attached tab image-diff-container" data-tab="diff-swipe">
<div class="diff-swipe"> <div class="diff-swipe">
<div class="swipe-frame"> <div class="swipe-frame">
@ -102,7 +76,7 @@
</div> </div>
{{end}} {{end}}
</div> </div>
<div class="ui active centered inline loader"></div> <div class="ui active centered inline loader mb-4"></div>
</div> </div>
</td> </td>
</tr> </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() { export default async function initImageDiff() {
function createContext(image1, image2) { function createContext(image1, image2) {
const size1 = { const size1 = {
@ -30,34 +61,50 @@ export default async function initImageDiff() {
$('.image-diff').each(function() { $('.image-diff').each(function() {
const $container = $(this); const $container = $(this);
const diffContainerWidth = $container.width() - 300;
const pathAfter = $container.data('path-after'); const pathAfter = $container.data('path-after');
const pathBefore = $container.data('path-before'); const pathBefore = $container.data('path-before');
const imageInfos = [{ const imageInfos = [{
loaded: false, loaded: false,
path: pathAfter, path: pathAfter,
$image: $container.find('img.image-after') $image: $container.find('img.image-after'),
$boundsInfo: $container.find('.bounds-info-after')
}, { }, {
loaded: false, loaded: false,
path: pathBefore, 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) { for (const info of imageInfos) {
if (info.$image.length > 0) { if (info.$image.length > 0) {
info.$image.on('load', () => { $.ajax({
info.loaded = true; url: info.path,
setReadyIfLoaded(); 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 { } else {
info.loaded = true; info.loaded = true;
setReadyIfLoaded(); setReadyIfLoaded();
} }
} }
const diffContainerWidth = $container.width() - 300;
function setReadyIfLoaded() { function setReadyIfLoaded() {
if (imageInfos[0].loaded && imageInfos[1].loaded) { if (imageInfos[0].loaded && imageInfos[1].loaded) {
initViews(imageInfos[0].$image, imageInfos[1].$image); initViews(imageInfos[0].$image, imageInfos[1].$image);
@ -81,6 +128,17 @@ export default async function initImageDiff() {
factor = (diffContainerWidth - 24) / 2 / sizes.max.width; 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({ sizes.image1.css({
width: sizes.size1.width * factor, width: sizes.size1.width * factor,
height: sizes.size1.height * factor height: sizes.size1.height * factor

Loading…
Cancel
Save