diff options
111 files changed, 1226 insertions, 837 deletions
diff --git a/.eslintrc.cjs b/.eslintrc.cjs index f9e1050240..57c6b19600 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -91,6 +91,7 @@ module.exports = { plugins: ['@vitest/eslint-plugin'], globals: vitestPlugin.environments.env.globals, rules: { + 'github/unescaped-html-literal': [0], '@vitest/consistent-test-filename': [0], '@vitest/consistent-test-it': [0], '@vitest/expect-expect': [0], @@ -423,7 +424,7 @@ module.exports = { 'github/no-useless-passive': [2], 'github/prefer-observers': [2], 'github/require-passive-events': [2], - 'github/unescaped-html-literal': [0], + 'github/unescaped-html-literal': [2], 'grouped-accessor-pairs': [2], 'guard-for-in': [0], 'id-blacklist': [0], diff --git a/modules/fileicon/entry.go b/modules/fileicon/entry.go index e4ded363e5..0326c2bfa8 100644 --- a/modules/fileicon/entry.go +++ b/modules/fileicon/entry.go @@ -6,17 +6,17 @@ package fileicon import "code.gitea.io/gitea/modules/git" type EntryInfo struct { - FullName string + BaseName string EntryMode git.EntryMode SymlinkToMode git.EntryMode IsOpen bool } -func EntryInfoFromGitTreeEntry(gitEntry *git.TreeEntry) *EntryInfo { - ret := &EntryInfo{FullName: gitEntry.Name(), EntryMode: gitEntry.Mode()} +func EntryInfoFromGitTreeEntry(commit *git.Commit, fullPath string, gitEntry *git.TreeEntry) *EntryInfo { + ret := &EntryInfo{BaseName: gitEntry.Name(), EntryMode: gitEntry.Mode()} if gitEntry.IsLink() { - if te, err := gitEntry.FollowLink(); err == nil && te.IsDir() { - ret.SymlinkToMode = te.Mode() + if res, err := git.EntryFollowLink(commit, fullPath, gitEntry); err == nil && res.TargetEntry.IsDir() { + ret.SymlinkToMode = res.TargetEntry.Mode() } } return ret diff --git a/modules/fileicon/material.go b/modules/fileicon/material.go index 449f527ee8..5361592d8a 100644 --- a/modules/fileicon/material.go +++ b/modules/fileicon/material.go @@ -5,7 +5,6 @@ package fileicon import ( "html/template" - "path" "strings" "sync" @@ -134,7 +133,7 @@ func (m *MaterialIconProvider) FindIconName(entry *EntryInfo) string { return "folder-git" } - fileNameLower := strings.ToLower(path.Base(entry.FullName)) + fileNameLower := strings.ToLower(entry.BaseName) if entry.EntryMode.IsDir() { if s, ok := m.rules.FolderNames[fileNameLower]; ok { return s diff --git a/modules/fileicon/material_test.go b/modules/fileicon/material_test.go index 68353d2189..d2a769eaac 100644 --- a/modules/fileicon/material_test.go +++ b/modules/fileicon/material_test.go @@ -20,8 +20,8 @@ func TestMain(m *testing.M) { func TestFindIconName(t *testing.T) { unittest.PrepareTestEnv(t) p := fileicon.DefaultMaterialIconProvider() - assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.php", EntryMode: git.EntryModeBlob})) - assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.PHP", EntryMode: git.EntryModeBlob})) - assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.js", EntryMode: git.EntryModeBlob})) - assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{FullName: "foo.vba", EntryMode: git.EntryModeBlob})) + assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.php", EntryMode: git.EntryModeBlob})) + assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.PHP", EntryMode: git.EntryModeBlob})) + assert.Equal(t, "javascript", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.js", EntryMode: git.EntryModeBlob})) + assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.vba", EntryMode: git.EntryModeBlob})) } diff --git a/modules/git/blob.go b/modules/git/blob.go index ab9deec8d1..40d8f44e79 100644 --- a/modules/git/blob.go +++ b/modules/git/blob.go @@ -22,17 +22,22 @@ func (b *Blob) Name() string { return b.name } -// GetBlobContent Gets the limited content of the blob as raw text -func (b *Blob) GetBlobContent(limit int64) (string, error) { +// GetBlobBytes Gets the limited content of the blob +func (b *Blob) GetBlobBytes(limit int64) ([]byte, error) { if limit <= 0 { - return "", nil + return nil, nil } dataRc, err := b.DataAsync() if err != nil { - return "", err + return nil, err } defer dataRc.Close() - buf, err := util.ReadWithLimit(dataRc, int(limit)) + return util.ReadWithLimit(dataRc, int(limit)) +} + +// GetBlobContent Gets the limited content of the blob as raw text +func (b *Blob) GetBlobContent(limit int64) (string, error) { + buf, err := b.GetBlobBytes(limit) return string(buf), err } @@ -99,11 +104,9 @@ loop: // GuessContentType guesses the content type of the blob. func (b *Blob) GuessContentType() (typesniffer.SniffedType, error) { - r, err := b.DataAsync() + buf, err := b.GetBlobBytes(typesniffer.SniffContentSize) if err != nil { return typesniffer.SniffedType{}, err } - defer r.Close() - - return typesniffer.DetectContentTypeFromReader(r) + return typesniffer.DetectContentType(buf), nil } diff --git a/modules/git/commit.go b/modules/git/commit.go index 1c1648eb8b..ed4876e7b3 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -20,7 +20,8 @@ import ( // Commit represents a git commit. type Commit struct { - Tree + Tree // FIXME: bad design, this field can be nil if the commit is from "last commit cache" + ID ObjectID // The ID of this commit object Author *Signature Committer *Signature diff --git a/modules/git/error.go b/modules/git/error.go index 6c86d1b04d..7d131345d0 100644 --- a/modules/git/error.go +++ b/modules/git/error.go @@ -32,22 +32,6 @@ func (err ErrNotExist) Unwrap() error { return util.ErrNotExist } -// ErrSymlinkUnresolved entry.FollowLink error -type ErrSymlinkUnresolved struct { - Name string - Message string -} - -func (err ErrSymlinkUnresolved) Error() string { - return fmt.Sprintf("%s: %s", err.Name, err.Message) -} - -// IsErrSymlinkUnresolved if some error is ErrSymlinkUnresolved -func IsErrSymlinkUnresolved(err error) bool { - _, ok := err.(ErrSymlinkUnresolved) - return ok -} - // ErrBranchNotExist represents a "BranchNotExist" kind of error. type ErrBranchNotExist struct { Name string diff --git a/modules/git/tree_blob_nogogit.go b/modules/git/tree_blob_nogogit.go index b7bcf40edd..b18d0fa05e 100644 --- a/modules/git/tree_blob_nogogit.go +++ b/modules/git/tree_blob_nogogit.go @@ -11,7 +11,7 @@ import ( ) // GetTreeEntryByPath get the tree entries according the sub dir -func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) { +func (t *Tree) GetTreeEntryByPath(relpath string) (_ *TreeEntry, err error) { if len(relpath) == 0 { return &TreeEntry{ ptree: t, @@ -21,27 +21,25 @@ func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) { }, nil } - // FIXME: This should probably use git cat-file --batch to be a bit more efficient relpath = path.Clean(relpath) parts := strings.Split(relpath, "/") - var err error + tree := t - for i, name := range parts { - if i == len(parts)-1 { - entries, err := tree.ListEntries() - if err != nil { - return nil, err - } - for _, v := range entries { - if v.Name() == name { - return v, nil - } - } - } else { - tree, err = tree.SubTree(name) - if err != nil { - return nil, err - } + for _, name := range parts[:len(parts)-1] { + tree, err = tree.SubTree(name) + if err != nil { + return nil, err + } + } + + name := parts[len(parts)-1] + entries, err := tree.ListEntries() + if err != nil { + return nil, err + } + for _, v := range entries { + if v.Name() == name { + return v, nil } } return nil, ErrNotExist{"", relpath} diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go index 57856d90ee..5099d8ee79 100644 --- a/modules/git/tree_entry.go +++ b/modules/git/tree_entry.go @@ -5,7 +5,7 @@ package git import ( - "io" + "path" "sort" "strings" @@ -24,77 +24,57 @@ func (te *TreeEntry) Type() string { } } -// FollowLink returns the entry pointed to by a symlink -func (te *TreeEntry) FollowLink() (*TreeEntry, error) { +type EntryFollowResult struct { + SymlinkContent string + TargetFullPath string + TargetEntry *TreeEntry +} + +func EntryFollowLink(commit *Commit, fullPath string, te *TreeEntry) (*EntryFollowResult, error) { if !te.IsLink() { - return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"} + return nil, util.ErrorWrap(util.ErrUnprocessableContent, "%q is not a symlink", fullPath) } - // read the link - r, err := te.Blob().DataAsync() - if err != nil { - return nil, err + // git's filename max length is 4096, hopefully a link won't be longer than multiple of that + const maxSymlinkSize = 20 * 4096 + if te.Blob().Size() > maxSymlinkSize { + return nil, util.ErrorWrap(util.ErrUnprocessableContent, "%q content exceeds symlink limit", fullPath) } - closed := false - defer func() { - if !closed { - _ = r.Close() - } - }() - buf := make([]byte, te.Size()) - _, err = io.ReadFull(r, buf) + + link, err := te.Blob().GetBlobContent(maxSymlinkSize) if err != nil { return nil, err } - _ = r.Close() - closed = true - - lnk := string(buf) - t := te.ptree - - // traverse up directories - for ; t != nil && strings.HasPrefix(lnk, "../"); lnk = lnk[3:] { - t = t.ptree + if strings.HasPrefix(link, "/") { + // It's said that absolute path will be stored as is in Git + return &EntryFollowResult{SymlinkContent: link}, util.ErrorWrap(util.ErrUnprocessableContent, "%q is an absolute symlink", fullPath) } - if t == nil { - return nil, ErrSymlinkUnresolved{te.Name(), "points outside of repo"} - } - - target, err := t.GetTreeEntryByPath(lnk) + targetFullPath := path.Join(path.Dir(fullPath), link) + targetEntry, err := commit.GetTreeEntryByPath(targetFullPath) if err != nil { - if IsErrNotExist(err) { - return nil, ErrSymlinkUnresolved{te.Name(), "broken link"} - } - return nil, err + return &EntryFollowResult{SymlinkContent: link}, err } - return target, nil + return &EntryFollowResult{SymlinkContent: link, TargetFullPath: targetFullPath, TargetEntry: targetEntry}, nil } -// FollowLinks returns the entry ultimately pointed to by a symlink -func (te *TreeEntry) FollowLinks(optLimit ...int) (*TreeEntry, error) { - if !te.IsLink() { - return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"} - } +func EntryFollowLinks(commit *Commit, firstFullPath string, firstTreeEntry *TreeEntry, optLimit ...int) (res *EntryFollowResult, err error) { limit := util.OptionalArg(optLimit, 10) - entry := te + treeEntry, fullPath := firstTreeEntry, firstFullPath for range limit { - if !entry.IsLink() { - break - } - next, err := entry.FollowLink() + res, err = EntryFollowLink(commit, fullPath, treeEntry) if err != nil { - return nil, err + return res, err } - if next.ID == entry.ID { - return nil, ErrSymlinkUnresolved{entry.Name(), "recursive link"} + treeEntry, fullPath = res.TargetEntry, res.TargetFullPath + if !treeEntry.IsLink() { + break } - entry = next } - if entry.IsLink() { - return nil, ErrSymlinkUnresolved{te.Name(), "too many levels of symbolic links"} + if treeEntry.IsLink() { + return res, util.ErrorWrap(util.ErrUnprocessableContent, "%q has too many links", firstFullPath) } - return entry, nil + return res, nil } // returns the Tree pointed to by this TreeEntry, or nil if this is not a tree diff --git a/modules/git/tree_entry_common_test.go b/modules/git/tree_entry_common_test.go new file mode 100644 index 0000000000..8b63bbb993 --- /dev/null +++ b/modules/git/tree_entry_common_test.go @@ -0,0 +1,76 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package git + +import ( + "testing" + + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFollowLink(t *testing.T) { + r, err := openRepositoryWithDefaultContext("tests/repos/repo1_bare") + require.NoError(t, err) + defer r.Close() + + commit, err := r.GetCommit("37991dec2c8e592043f47155ce4808d4580f9123") + require.NoError(t, err) + + // get the symlink + { + lnkFullPath := "foo/bar/link_to_hello" + lnk, err := commit.Tree.GetTreeEntryByPath("foo/bar/link_to_hello") + require.NoError(t, err) + assert.True(t, lnk.IsLink()) + + // should be able to dereference to target + res, err := EntryFollowLink(commit, lnkFullPath, lnk) + require.NoError(t, err) + assert.Equal(t, "hello", res.TargetEntry.Name()) + assert.Equal(t, "foo/nar/hello", res.TargetFullPath) + assert.False(t, res.TargetEntry.IsLink()) + assert.Equal(t, "b14df6442ea5a1b382985a6549b85d435376c351", res.TargetEntry.ID.String()) + } + + { + // should error when called on a normal file + entry, err := commit.Tree.GetTreeEntryByPath("file1.txt") + require.NoError(t, err) + res, err := EntryFollowLink(commit, "file1.txt", entry) + assert.ErrorIs(t, err, util.ErrUnprocessableContent) + assert.Nil(t, res) + } + + { + // should error for broken links + entry, err := commit.Tree.GetTreeEntryByPath("foo/broken_link") + require.NoError(t, err) + assert.True(t, entry.IsLink()) + res, err := EntryFollowLink(commit, "foo/broken_link", entry) + assert.ErrorIs(t, err, util.ErrNotExist) + assert.Equal(t, "nar/broken_link", res.SymlinkContent) + } + + { + // should error for external links + entry, err := commit.Tree.GetTreeEntryByPath("foo/outside_repo") + require.NoError(t, err) + assert.True(t, entry.IsLink()) + res, err := EntryFollowLink(commit, "foo/outside_repo", entry) + assert.ErrorIs(t, err, util.ErrNotExist) + assert.Equal(t, "../../outside_repo", res.SymlinkContent) + } + + { + // testing fix for short link bug + entry, err := commit.Tree.GetTreeEntryByPath("foo/link_short") + require.NoError(t, err) + res, err := EntryFollowLink(commit, "foo/link_short", entry) + assert.ErrorIs(t, err, util.ErrNotExist) + assert.Equal(t, "a", res.SymlinkContent) + } +} diff --git a/modules/git/tree_entry_gogit.go b/modules/git/tree_entry_gogit.go index eb9b012681..e6845f1c77 100644 --- a/modules/git/tree_entry_gogit.go +++ b/modules/git/tree_entry_gogit.go @@ -19,16 +19,12 @@ type TreeEntry struct { gogitTreeEntry *object.TreeEntry ptree *Tree - size int64 - sized bool - fullName string + size int64 + sized bool } // Name returns the name of the entry func (te *TreeEntry) Name() string { - if te.fullName != "" { - return te.fullName - } return te.gogitTreeEntry.Name } @@ -55,7 +51,7 @@ func (te *TreeEntry) Size() int64 { return te.size } -// IsSubModule if the entry is a sub module +// IsSubModule if the entry is a submodule func (te *TreeEntry) IsSubModule() bool { return te.gogitTreeEntry.Mode == filemode.Submodule } diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go index d815a8bc2e..f36c07bc2a 100644 --- a/modules/git/tree_entry_mode.go +++ b/modules/git/tree_entry_mode.go @@ -15,7 +15,7 @@ type EntryMode int // one of these. const ( // EntryModeNoEntry is possible if the file was added or removed in a commit. In the case of - // added the base commit will not have the file in its tree so a mode of 0o000000 is used. + // when adding the base commit doesn't have the file in its tree, a mode of 0o000000 is used. EntryModeNoEntry EntryMode = 0o000000 EntryModeBlob EntryMode = 0o100644 @@ -30,7 +30,7 @@ func (e EntryMode) String() string { return strconv.FormatInt(int64(e), 8) } -// IsSubModule if the entry is a sub module +// IsSubModule if the entry is a submodule func (e EntryMode) IsSubModule() bool { return e == EntryModeCommit } diff --git a/modules/git/tree_entry_nogogit.go b/modules/git/tree_entry_nogogit.go index 38a768e3a6..8fad96cdf8 100644 --- a/modules/git/tree_entry_nogogit.go +++ b/modules/git/tree_entry_nogogit.go @@ -57,7 +57,7 @@ func (te *TreeEntry) Size() int64 { return te.size } -// IsSubModule if the entry is a sub module +// IsSubModule if the entry is a submodule func (te *TreeEntry) IsSubModule() bool { return te.entryMode.IsSubModule() } diff --git a/modules/git/tree_entry_test.go b/modules/git/tree_entry_test.go index 30eee13669..9ca82675e0 100644 --- a/modules/git/tree_entry_test.go +++ b/modules/git/tree_entry_test.go @@ -53,50 +53,3 @@ func TestEntriesCustomSort(t *testing.T) { assert.Equal(t, "bcd", entries[6].Name()) assert.Equal(t, "abc", entries[7].Name()) } - -func TestFollowLink(t *testing.T) { - r, err := openRepositoryWithDefaultContext("tests/repos/repo1_bare") - assert.NoError(t, err) - defer r.Close() - - commit, err := r.GetCommit("37991dec2c8e592043f47155ce4808d4580f9123") - assert.NoError(t, err) - - // get the symlink - lnk, err := commit.Tree.GetTreeEntryByPath("foo/bar/link_to_hello") - assert.NoError(t, err) - assert.True(t, lnk.IsLink()) - - // should be able to dereference to target - target, err := lnk.FollowLink() - assert.NoError(t, err) - assert.Equal(t, "hello", target.Name()) - assert.False(t, target.IsLink()) - assert.Equal(t, "b14df6442ea5a1b382985a6549b85d435376c351", target.ID.String()) - - // should error when called on normal file - target, err = commit.Tree.GetTreeEntryByPath("file1.txt") - assert.NoError(t, err) - _, err = target.FollowLink() - assert.EqualError(t, err, "file1.txt: not a symlink") - - // should error for broken links - target, err = commit.Tree.GetTreeEntryByPath("foo/broken_link") - assert.NoError(t, err) - assert.True(t, target.IsLink()) - _, err = target.FollowLink() - assert.EqualError(t, err, "broken_link: broken link") - - // should error for external links - target, err = commit.Tree.GetTreeEntryByPath("foo/outside_repo") - assert.NoError(t, err) - assert.True(t, target.IsLink()) - _, err = target.FollowLink() - assert.EqualError(t, err, "outside_repo: points outside of repo") - - // testing fix for short link bug - target, err = commit.Tree.GetTreeEntryByPath("foo/link_short") - assert.NoError(t, err) - _, err = target.FollowLink() - assert.EqualError(t, err, "link_short: broken link") -} diff --git a/modules/git/tree_gogit.go b/modules/git/tree_gogit.go index 421b0ecb0f..272b018ffd 100644 --- a/modules/git/tree_gogit.go +++ b/modules/git/tree_gogit.go @@ -69,7 +69,7 @@ func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) { seen := map[plumbing.Hash]bool{} walker := object.NewTreeWalker(t.gogitTree, true, seen) for { - fullName, entry, err := walker.Next() + _, entry, err := walker.Next() if err == io.EOF { break } @@ -84,7 +84,6 @@ func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) { ID: ParseGogitHash(entry.Hash), gogitTreeEntry: &entry, ptree: t, - fullName: fullName, } entries = append(entries, convertedEntry) } diff --git a/modules/markup/console/console.go b/modules/markup/console/console.go index 06f3acfa68..492579b0a5 100644 --- a/modules/markup/console/console.go +++ b/modules/markup/console/console.go @@ -6,13 +6,14 @@ package console import ( "bytes" "io" - "path" + "unicode/utf8" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" trend "github.com/buildkite/terminal-to-html/v3" - "github.com/go-enry/go-enry/v2" ) func init() { @@ -22,6 +23,8 @@ func init() { // Renderer implements markup.Renderer type Renderer struct{} +var _ markup.RendererContentDetector = (*Renderer)(nil) + // Name implements markup.Renderer func (Renderer) Name() string { return "console" @@ -40,15 +43,36 @@ func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule { } // CanRender implements markup.RendererContentDetector -func (Renderer) CanRender(filename string, input io.Reader) bool { - buf, err := io.ReadAll(input) - if err != nil { +func (Renderer) CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool { + if !sniffedType.IsTextPlain() { return false } - if enry.GetLanguage(path.Base(filename), buf) != enry.OtherLanguage { + + s := util.UnsafeBytesToString(prefetchBuf) + rs := []rune(s) + cnt := 0 + firstErrPos := -1 + isCtrlSep := func(p int) bool { + return p < len(rs) && (rs[p] == ';' || rs[p] == 'm') + } + for i, c := range rs { + if c == 0 { + return false + } + if c == '\x1b' { + match := i+1 < len(rs) && rs[i+1] == '[' + if match && (isCtrlSep(i+2) || isCtrlSep(i+3) || isCtrlSep(i+4) || isCtrlSep(i+5)) { + cnt++ + } + } + if c == utf8.RuneError && firstErrPos == -1 { + firstErrPos = i + } + } + if firstErrPos != -1 && firstErrPos != len(rs)-1 { return false } - return bytes.ContainsRune(buf, '\x1b') + return cnt >= 2 // only render it as console output if there are at least two escape sequences } // Render renders terminal colors to HTML with all specific handling stuff. diff --git a/modules/markup/console/console_test.go b/modules/markup/console/console_test.go index 539f965ea1..d1192bebc2 100644 --- a/modules/markup/console/console_test.go +++ b/modules/markup/console/console_test.go @@ -8,23 +8,39 @@ import ( "testing" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/typesniffer" "github.com/stretchr/testify/assert" ) func TestRenderConsole(t *testing.T) { - var render Renderer - kases := map[string]string{ - "\x1b[37m\x1b[40mnpm\x1b[0m \x1b[0m\x1b[32minfo\x1b[0m \x1b[0m\x1b[35mit worked if it ends with\x1b[0m ok": "<span class=\"term-fg37 term-bg40\">npm</span> <span class=\"term-fg32\">info</span> <span class=\"term-fg35\">it worked if it ends with</span> ok", + cases := []struct { + input string + expected string + }{ + {"\x1b[37m\x1b[40mnpm\x1b[0m \x1b[0m\x1b[32minfo\x1b[0m \x1b[0m\x1b[35mit worked if it ends with\x1b[0m ok", `<span class="term-fg37 term-bg40">npm</span> <span class="term-fg32">info</span> <span class="term-fg35">it worked if it ends with</span> ok`}, + {"\x1b[1;2m \x1b[123m 啊", `<span class="term-fg2"> 啊</span>`}, + {"\x1b[1;2m \x1b[123m \xef", `<span class="term-fg2"> �</span>`}, + {"\x1b[1;2m \x1b[123m \xef \xef", ``}, + {"\x1b[12", ``}, + {"\x1b[1", ``}, + {"\x1b[FOO\x1b[", ``}, + {"\x1b[mFOO\x1b[m", `FOO`}, } - for k, v := range kases { + var render Renderer + for i, c := range cases { var buf strings.Builder - canRender := render.CanRender("test", strings.NewReader(k)) - assert.True(t, canRender) + st := typesniffer.DetectContentType([]byte(c.input)) + canRender := render.CanRender("test", st, []byte(c.input)) + if c.expected == "" { + assert.False(t, canRender, "case %d: expected not to render", i) + continue + } - err := render.Render(markup.NewRenderContext(t.Context()), strings.NewReader(k), &buf) + assert.True(t, canRender) + err := render.Render(markup.NewRenderContext(t.Context()), strings.NewReader(c.input), &buf) assert.NoError(t, err) - assert.Equal(t, v, buf.String()) + assert.Equal(t, c.expected, buf.String()) } } diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index 35f90eb46c..b6e9c348b7 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -4,12 +4,12 @@ package markup import ( - "bytes" "io" "path" "strings" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" ) // Renderer defines an interface for rendering markup file to HTML @@ -37,7 +37,7 @@ type ExternalRenderer interface { // RendererContentDetector detects if the content can be rendered // by specified renderer type RendererContentDetector interface { - CanRender(filename string, input io.Reader) bool + CanRender(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) bool } var ( @@ -60,13 +60,9 @@ func GetRendererByFileName(filename string) Renderer { } // DetectRendererType detects the markup type of the content -func DetectRendererType(filename string, input io.Reader) string { - buf, err := io.ReadAll(input) - if err != nil { - return "" - } +func DetectRendererType(filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte) string { for _, renderer := range renderers { - if detector, ok := renderer.(RendererContentDetector); ok && detector.CanRender(filename, bytes.NewReader(buf)) { + if detector, ok := renderer.(RendererContentDetector); ok && detector.CanRender(filename, sniffedType, prefetchBuf) { return renderer.Name() } } diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go index f7c6d10ba0..c68b59a897 100644 --- a/modules/structs/admin_user.go +++ b/modules/structs/admin_user.go @@ -8,8 +8,11 @@ import "time" // CreateUserOption create user options type CreateUserOption struct { - SourceID int64 `json:"source_id"` + SourceID int64 `json:"source_id"` + // identifier of the user, provided by the external authenticator (if configured) + // default: empty LoginName string `json:"login_name"` + // username of the user // required: true Username string `json:"username" binding:"Required;Username;MaxSize(40)"` FullName string `json:"full_name" binding:"MaxSize(100)"` @@ -32,6 +35,8 @@ type CreateUserOption struct { type EditUserOption struct { // required: true SourceID int64 `json:"source_id"` + // identifier of the user, provided by the external authenticator (if configured) + // default: empty // required: true LoginName string `json:"login_name" binding:"Required"` // swagger:strfmt email diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 6e0b66ef55..ac779a5740 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -71,7 +71,8 @@ type PayloadUser struct { // Full name of the commit author Name string `json:"name"` // swagger:strfmt email - Email string `json:"email"` + Email string `json:"email"` + // username of the user UserName string `json:"username"` } diff --git a/modules/structs/issue_tracked_time.go b/modules/structs/issue_tracked_time.go index a3904af80e..befcfb323d 100644 --- a/modules/structs/issue_tracked_time.go +++ b/modules/structs/issue_tracked_time.go @@ -14,7 +14,7 @@ type AddTimeOption struct { Time int64 `json:"time" binding:"Required"` // swagger:strfmt date-time Created time.Time `json:"created"` - // User who spent the time (optional) + // username of the user who spent the time working on the issue (optional) User string `json:"user_name"` } @@ -26,7 +26,8 @@ type TrackedTime struct { // Time in seconds Time int64 `json:"time"` // deprecated (only for backwards compatibility) - UserID int64 `json:"user_id"` + UserID int64 `json:"user_id"` + // username of the user UserName string `json:"user_name"` // deprecated (only for backwards compatibility) IssueID int64 `json:"issue_id"` diff --git a/modules/structs/org.go b/modules/structs/org.go index f93b3b6493..33b45c6344 100644 --- a/modules/structs/org.go +++ b/modules/structs/org.go @@ -15,6 +15,7 @@ type Organization struct { Location string `json:"location"` Visibility string `json:"visibility"` RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"` + // username of the organization // deprecated UserName string `json:"username"` } @@ -30,6 +31,7 @@ type OrganizationPermissions struct { // CreateOrgOption options for creating an organization type CreateOrgOption struct { + // username of the organization // required: true UserName string `json:"username" binding:"Required;Username;MaxSize(40)"` FullName string `json:"full_name" binding:"MaxSize(100)"` diff --git a/modules/structs/user.go b/modules/structs/user.go index 7338e45739..89349cda2c 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -15,9 +15,9 @@ import ( type User struct { // the user's id ID int64 `json:"id"` - // the user's username + // login of the user, same as `username` UserName string `json:"login"` - // the user's authentication sign-in name. + // identifier of the user, provided by the external authenticator (if configured) // default: empty LoginName string `json:"login_name"` // The ID of the user's Authentication Source diff --git a/modules/structs/user_email.go b/modules/structs/user_email.go index 9319667e8f..01895a0058 100644 --- a/modules/structs/user_email.go +++ b/modules/structs/user_email.go @@ -11,6 +11,7 @@ type Email struct { Verified bool `json:"verified"` Primary bool `json:"primary"` UserID int64 `json:"user_id"` + // username of the user UserName string `json:"username"` } diff --git a/modules/typesniffer/typesniffer.go b/modules/typesniffer/typesniffer.go index 8cb3d278ce..2e8d9c4a1e 100644 --- a/modules/typesniffer/typesniffer.go +++ b/modules/typesniffer/typesniffer.go @@ -6,18 +6,14 @@ package typesniffer import ( "bytes" "encoding/binary" - "fmt" - "io" "net/http" "regexp" "slices" "strings" - - "code.gitea.io/gitea/modules/util" + "sync" ) -// Use at most this many bytes to determine Content Type. -const sniffLen = 1024 +const SniffContentSize = 1024 const ( MimeTypeImageSvg = "image/svg+xml" @@ -26,22 +22,30 @@ const ( MimeTypeApplicationOctetStream = "application/octet-stream" ) -var ( - svgComment = regexp.MustCompile(`(?s)<!--.*?-->`) - svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`) - svgTagInXMLRegex = regexp.MustCompile(`(?si)\A<\?xml\b.*?\?>\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`) -) - -// SniffedType contains information about a blobs type. +var globalVars = sync.OnceValue(func() (ret struct { + svgComment, svgTagRegex, svgTagInXMLRegex *regexp.Regexp +}, +) { + ret.svgComment = regexp.MustCompile(`(?s)<!--.*?-->`) + ret.svgTagRegex = regexp.MustCompile(`(?si)\A\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`) + ret.svgTagInXMLRegex = regexp.MustCompile(`(?si)\A<\?xml\b.*?\?>\s*(?:(<!DOCTYPE\s+svg([\s:]+.*?>|>))\s*)*<svg\b`) + return ret +}) + +// SniffedType contains information about a blob's type. type SniffedType struct { contentType string } -// IsText etects if content format is plain text. +// IsText detects if the content format is text family, including text/plain, text/html, text/css, etc. func (ct SniffedType) IsText() bool { return strings.Contains(ct.contentType, "text/") } +func (ct SniffedType) IsTextPlain() bool { + return strings.Contains(ct.contentType, "text/plain") +} + // IsImage detects if data is an image format func (ct SniffedType) IsImage() bool { return strings.Contains(ct.contentType, "image/") @@ -57,12 +61,12 @@ func (ct SniffedType) IsPDF() bool { return strings.Contains(ct.contentType, "application/pdf") } -// IsVideo detects if data is an video format +// IsVideo detects if data is a video format func (ct SniffedType) IsVideo() bool { return strings.Contains(ct.contentType, "video/") } -// IsAudio detects if data is an video format +// IsAudio detects if data is a video format func (ct SniffedType) IsAudio() bool { return strings.Contains(ct.contentType, "audio/") } @@ -103,33 +107,34 @@ func detectFileTypeBox(data []byte) (brands []string, found bool) { return brands, true } -// DetectContentType extends http.DetectContentType with more content types. Defaults to text/unknown if input is empty. +// DetectContentType extends http.DetectContentType with more content types. Defaults to text/plain if input is empty. func DetectContentType(data []byte) SniffedType { if len(data) == 0 { - return SniffedType{"text/unknown"} + return SniffedType{"text/plain"} } ct := http.DetectContentType(data) - if len(data) > sniffLen { - data = data[:sniffLen] + if len(data) > SniffContentSize { + data = data[:SniffContentSize] } + vars := globalVars() // SVG is unsupported by http.DetectContentType, https://github.com/golang/go/issues/15888 detectByHTML := strings.Contains(ct, "text/plain") || strings.Contains(ct, "text/html") detectByXML := strings.Contains(ct, "text/xml") if detectByHTML || detectByXML { - dataProcessed := svgComment.ReplaceAll(data, nil) + dataProcessed := vars.svgComment.ReplaceAll(data, nil) dataProcessed = bytes.TrimSpace(dataProcessed) - if detectByHTML && svgTagRegex.Match(dataProcessed) || - detectByXML && svgTagInXMLRegex.Match(dataProcessed) { + if detectByHTML && vars.svgTagRegex.Match(dataProcessed) || + detectByXML && vars.svgTagInXMLRegex.Match(dataProcessed) { ct = MimeTypeImageSvg } } if strings.HasPrefix(ct, "audio/") && bytes.HasPrefix(data, []byte("ID3")) { // The MP3 detection is quite inaccurate, any content with "ID3" prefix will result in "audio/mpeg". - // So remove the "ID3" prefix and detect again, if result is text, then it must be text content. + // So remove the "ID3" prefix and detect again, then if the result is "text", it must be text content. // This works especially because audio files contain many unprintable/invalid characters like `0x00` ct2 := http.DetectContentType(data[3:]) if strings.HasPrefix(ct2, "text/") { @@ -155,15 +160,3 @@ func DetectContentType(data []byte) SniffedType { } 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 := util.ReadAtMost(r, buf) - if err != nil { - return SniffedType{}, fmt.Errorf("DetectContentTypeFromReader io error: %w", err) - } - buf = buf[:n] - - return DetectContentType(buf), nil -} diff --git a/modules/typesniffer/typesniffer_test.go b/modules/typesniffer/typesniffer_test.go index 3e5db3308b..a0c824b912 100644 --- a/modules/typesniffer/typesniffer_test.go +++ b/modules/typesniffer/typesniffer_test.go @@ -4,7 +4,6 @@ package typesniffer import ( - "bytes" "encoding/base64" "encoding/hex" "strings" @@ -17,7 +16,7 @@ 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) + assert.NotEqual(t, "image/svg+xml", DetectContentType([]byte(`<!-- `+strings.Repeat("x", SniffContentSize)+` --><svg></svg>`)).contentType) } func TestIsTextFile(t *testing.T) { @@ -116,22 +115,13 @@ func TestIsAudio(t *testing.T) { assert.True(t, DetectContentType([]byte("ID3Toy\n====\t* hi 🌞, ..."+"🌛"[0:2])).IsText()) // test ID3 tag with incomplete UTF8 char } -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()) -} - func TestDetectContentTypeOgg(t *testing.T) { oggAudio, _ := hex.DecodeString("4f67675300020000000000000000352f0000000000007dc39163011e01766f72626973000000000244ac0000000000000071020000000000b8014f6767530000") - st, err := DetectContentTypeFromReader(bytes.NewReader(oggAudio)) - assert.NoError(t, err) + st := DetectContentType(oggAudio) assert.True(t, st.IsAudio()) oggVideo, _ := hex.DecodeString("4f676753000200000000000000007d9747ef000000009b59daf3012a807468656f7261030201001e00110001e000010e00020000001e00000001000001000001") - st, err = DetectContentTypeFromReader(bytes.NewReader(oggVideo)) - assert.NoError(t, err) + st = DetectContentType(oggVideo) assert.True(t, st.IsVideo()) } diff --git a/modules/util/error.go b/modules/util/error.go index 8e67d5a82f..6b2721618e 100644 --- a/modules/util/error.go +++ b/modules/util/error.go @@ -17,8 +17,8 @@ var ( ErrNotExist = errors.New("resource does not exist") // also implies HTTP 404 ErrAlreadyExist = errors.New("resource already exists") // also implies HTTP 409 - // ErrUnprocessableContent implies HTTP 422, syntax of the request content was correct, - // but server was unable to process the contained instructions + // ErrUnprocessableContent implies HTTP 422, the syntax of the request content is correct, + // but the server is unable to process the contained instructions ErrUnprocessableContent = errors.New("unprocessable content") ) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 80bf0801e9..f979fc814d 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2769,6 +2769,8 @@ branch.new_branch_from = Create new branch from "%s" branch.renamed = Branch %s was renamed to %s. branch.rename_default_or_protected_branch_error = Only admins can rename default or protected branches. branch.rename_protected_branch_failed = This branch is protected by glob-based protection rules. +branch.commits_divergence_from = Commits divergence: %[1]d behind and %[2]d ahead of %[3]s +branch.commits_no_divergence = The same as branch %[1]s tag.create_tag = Create tag %s tag.create_tag_operation = Create tag @@ -2782,6 +2784,7 @@ topic.done = Done topic.count_prompt = You cannot select more than 25 topics topic.format_prompt = Topics must start with a letter or number, can include dashes ('-') and dots ('.'), can be up to 35 characters long. Letters must be lowercase. +find_file.follow_symlink= Follow this symlink to where it is pointing at find_file.go_to_file = Go to file find_file.no_matching = No matching file found diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini index f2cfb93294..e8c90d059b 100644 --- a/options/locale/locale_ga-IE.ini +++ b/options/locale/locale_ga-IE.ini @@ -1969,6 +1969,7 @@ pulls.cmd_instruction_checkout_title=Seiceáil pulls.cmd_instruction_checkout_desc=Ó stór tionscadail, seiceáil brainse nua agus déan tástáil ar na hathruithe. pulls.cmd_instruction_merge_title=Cumaisc pulls.cmd_instruction_merge_desc=Cumaisc na hathruithe agus nuashonrú ar Gitea. +pulls.cmd_instruction_merge_warning=Rabhadh: Nà féidir iarratas tarraingthe cumaisc a dhéanamh leis an oibrÃocht seo mar nach bhfuil "autodetect manual merge" cumasaithe. pulls.clear_merge_message=Glan an teachtaireacht chumaisc pulls.clear_merge_message_hint=Má imrÃtear an teachtaireacht chumaisc nà bhainfear ach ábhar na teachtaireachta tiomanta agus coimeádfar leantóirà git ginte ar nós "Co-Authored-By …". diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 548b0d18f1..96a6d518e2 100644 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -420,8 +420,9 @@ remember_me=è®°ä½æ¤è®¾å¤‡ remember_me.compromised=登录令牌ä¸å†æœ‰æ•ˆï¼Œå› 为它å¯èƒ½è¡¨æ˜Žå¸æˆ·å·²è¢«ç ´åã€‚è¯·æ£€æŸ¥æ‚¨çš„å¸æˆ·æ˜¯å¦æœ‰å¼‚常活动。 forgot_password_title=忘记密ç forgot_password=忘记密ç ? -need_account=需è¦ä¸€ä¸ªå¸æˆ·? -sign_up_now=还没账å·ï¼Ÿé©¬ä¸Šæ³¨å†Œã€‚ +need_account=需è¦ä¸€ä¸ªå¸æˆ·ï¼Ÿ +sign_up_tip=您æ£åœ¨ç³»ç»Ÿä¸æ³¨å†Œç¬¬ä¸€ä¸ªå¸æˆ·ï¼Œå®ƒæ‹¥æœ‰ç®¡ç†å‘˜æƒé™ã€‚è¯·ä»”ç»†è®°ä½æ‚¨çš„用户å和密ç 。 å¦‚æžœæ‚¨å¿˜è®°äº†ç”¨æˆ·åæˆ–密ç ,请å‚阅 Gitea 文档以æ¢å¤è´¦æˆ·ã€‚ +sign_up_now=ç«‹å³æ³¨å†Œã€‚ sign_up_successful=叿ˆ·åˆ›å»ºæˆåŠŸã€‚æ¬¢è¿Žï¼ confirmation_mail_sent_prompt_ex=䏀尿–°çš„确认邮件已ç»å‘é€åˆ° <b>%s</b>。请在下一个 %s 䏿£€æŸ¥æ‚¨çš„æ”¶ä»¶ç®±ä»¥å®Œæˆæ³¨å†Œæµç¨‹ã€‚ 如果您的注册邮箱地å€ä¸æ£ç¡®ï¼Œæ‚¨å¯ä»¥é‡æ–°ç™»å½•并更改它。 must_change_password=更新您的密ç @@ -485,7 +486,7 @@ sspi_auth_failed=SSPI 认è¯å¤±è´¥ password_pwned=æ¤å¯†ç 出现在 <a target="_blank" rel="noopener noreferrer" href="%s">被盗密ç </a> 列表上并且曾ç»è¢«å…¬å¼€ã€‚ 请使用å¦ä¸€ä¸ªå¯†ç å†è¯•一次。 password_pwned_err=æ— æ³•å®Œæˆå¯¹ HaveIBeenPwned 的请求 last_admin=您ä¸èƒ½åˆ 除最åŽä¸€ä¸ªç®¡ç†å‘˜ã€‚必须至少ä¿ç•™ä¸€ä¸ªç®¡ç†å‘˜ã€‚ -signin_passkey=使用密钥登录 +signin_passkey=使用通行密钥登录 back_to_sign_in=è¿”å›žç™»å½•é¡µé¢ [mail] @@ -518,7 +519,7 @@ register_success=注册æˆåŠŸ issue_assigned.pull=@%[1]s 已将仓库 %[3]s ä¸çš„åˆå¹¶è¯·æ±‚ %[2]s 指派给您 issue_assigned.issue=@%[1]s 已将仓库 %[3]s ä¸çš„å·¥å• %[2]s 指派给您 -issue.x_mentioned_you=<b>@%s</b> æåˆ°äº†æ‚¨ï¼š +issue.x_mentioned_you=<b>@%s</b> æåŠäº†æ‚¨ï¼š issue.action.force_push=<b>%[1]s</b> 强制从 %[3]s æŽ¨é€ <b>%[2]s</b> 至 [4]s。 issue.action.push_1=<b>@%[1]s</b> 推é€äº† %[3]d 个æäº¤åˆ° %[2]s issue.action.push_n=<b>@%[1]s</b> 推é€äº† %[3]d 个æäº¤åˆ° %[2]s @@ -838,7 +839,7 @@ ssh_desc=这些 SSH 公钥已ç»å…³è”到您的账å·ã€‚相应的ç§é’¥æ‹¥æœ‰å®Œ principal_desc=这些 SSH è¯ä¹¦è§„则已关è”到您的账å·å°†å…许完全访问您所有仓库。 gpg_desc=这些 GPG 公钥已ç»å…³è”到您的账å·ã€‚请妥善ä¿ç®¡æ‚¨çš„ç§é’¥å› ä¸ºä»–ä»¬å°†è¢«ç”¨äºŽè®¤è¯æäº¤ã€‚ ssh_helper=<strong>需è¦å¸®åŠ©ï¼Ÿ</strong> 请查看有关 <a href="%s">å¦‚ä½•ç”Ÿæˆ SSH 密钥</a> 或 <a href="%s">å¸¸è§ SSH 问题</a> å¯»æ‰¾ç”æ¡ˆã€‚ -gpg_helper=<strong>需è¦å¸®åŠ©å—?</strong>看一看 GitHub <a href="%s">关于 GPG</a> 的指导。 +gpg_helper=<strong>需è¦å¸®åŠ©ï¼Ÿ</strong>看一看 GitHub <a href="%s">关于 GPG</a> 的指导。 add_new_key=å¢žåŠ SSH 密钥 add_new_gpg_key=æ·»åŠ çš„ GPG 密钥 key_content_ssh_placeholder=以 'ssh-ed25519'〠'ssh-rsa'〠'ecdsa-sha2-nistp256'ã€'ecdsa-sha2-nistp384'ã€'ecdsa-sha2-nistp521'〠'sk-ecdsa-sha2-nistp256@openssh.com' 或 'sk-ssh-ed25519@openssh.com' 开头 @@ -1016,10 +1017,10 @@ delete_account_title=åˆ é™¤å½“å‰å¸æˆ· delete_account_desc=ç¡®å®žè¦æ°¸ä¹…åˆ é™¤æ¤ç”¨æˆ·å¸æˆ·å—? email_notifications.enable=å¯ç”¨é‚®ä»¶é€šçŸ¥ -email_notifications.onmention=åªåœ¨è¢«æåˆ°æ—¶é‚®ä»¶é€šçŸ¥ +email_notifications.onmention=仅被æåŠæ—¶é€šçŸ¥ email_notifications.disable=åœç”¨é‚®ä»¶é€šçŸ¥ -email_notifications.submit=邮件通知设置 -email_notifications.andyourown=和您自己的通知 +email_notifications.submit=设置邮件通知 +email_notifications.andyourown=仅与您相关的通知 visibility=用户å¯è§æ€§ visibility.public=公开 @@ -1061,6 +1062,7 @@ fork_no_valid_owners=这个代ç ä»“åº“æ— æ³•è¢«æ´¾ç”Ÿï¼Œå› ä¸ºæ²¡æœ‰æœ‰æ•ˆçš„æ‰ fork.blocked_user=æ— æ³•å…‹éš†ä»“åº“ï¼Œå› ä¸ºæ‚¨è¢«ä»“åº“æ‰€æœ‰è€…å±è”½ã€‚ use_template=ä½¿ç”¨æ¤æ¨¡æ¿ open_with_editor=用 %s 打开 + download_zip=下载 ZIP download_tar=下载 TAR.GZ download_bundle=下载 BUNDLE @@ -1070,12 +1072,12 @@ repo_desc=æè¿° repo_desc_helper=è¾“å…¥ç®€è¦æè¿° (å¯é€‰) repo_no_desc=æ— è¯¦ç»†ä¿¡æ¯ repo_lang=è¯è¨€ -repo_gitignore_helper=选择 .gitignore 模æ¿ã€‚ +repo_gitignore_helper=选择 .gitignore æ¨¡æ¿ repo_gitignore_helper_desc=从常è§è¯è¨€çš„æ¨¡æ¿åˆ—表ä¸é€‰æ‹©å¿½ç•¥è·Ÿè¸ªçš„æ–‡ä»¶ã€‚é»˜è®¤æƒ…å†µä¸‹ï¼Œç”±å¼€å‘æˆ–构建工具生æˆçš„特殊文件都包å«åœ¨ .gitignore ä¸ã€‚ issue_labels=工啿 ‡ç¾ issue_labels_helper=é€‰æ‹©ä¸€ä¸ªå·¥å•æ ‡ç¾é›† license=授æƒè®¸å¯ -license_helper=选择授æƒè®¸å¯æ–‡ä»¶ã€‚ +license_helper=选择授æƒè®¸å¯æ–‡ä»¶ license_helper_desc=许å¯è¯è¯´æ˜Žäº†å…¶ä»–人å¯ä»¥å’Œä¸å¯ä»¥ç”¨æ‚¨çš„代ç åšä»€ä¹ˆã€‚ä¸ç¡®å®šå“ªä¸€ä¸ªé€‚åˆæ‚¨çš„é¡¹ç›®ï¼Ÿè§ <a target="_blank" rel="noopener noreferrer" href="%s">选择一个许å¯è¯</a> multiple_licenses=多许å¯è¯ object_format=å¯¹è±¡æ ¼å¼ @@ -1228,6 +1230,7 @@ migrate.migrating_issues=è¿ç§»å·¥å• migrate.migrating_pulls=è¿ç§»åˆå¹¶è¯·æ±‚ migrate.cancel_migrating_title=å–æ¶ˆè¿ç§» migrate.cancel_migrating_confirm=您想è¦å–æ¶ˆæ¤æ¬¡è¿ç§»å—? +migration_status=è¿ç§»çŠ¶æ€ mirror_from=镜åƒè‡ªåœ°å€ forked_from=派生自 @@ -1353,6 +1356,7 @@ editor.update=æ›´æ–° %s editor.delete=åˆ é™¤ %s editor.patch=åº”ç”¨è¡¥ä¸ editor.patching=打补ä¸ï¼š +editor.fail_to_apply_patch=æ— æ³•åº”ç”¨è¡¥ä¸ editor.new_patch=æ–°è¡¥ä¸ editor.commit_message_desc=æ·»åŠ ä¸€ä¸ªå¯é€‰çš„æ‰©å±•æè¿°... editor.signoff_desc=在æäº¤æ—¥å¿—æ¶ˆæ¯æœ«å°¾æ·»åŠ ç¾ç½²äººä¿¡æ¯ã€‚ @@ -1372,6 +1376,7 @@ editor.branch_already_exists=æ¤ä»“库已å˜åœ¨å为「%sã€çš„分支。 editor.directory_is_a_file=目录å「%sã€å·²ä½œä¸ºæ–‡ä»¶å在æ¤ä»“库ä¸å˜åœ¨ã€‚ editor.file_is_a_symlink=`「%sã€æ˜¯ä¸€ä¸ªç¬¦å·é“¾æŽ¥ï¼Œæ— 法在 Web 编辑器ä¸ç¼–辑` editor.filename_is_a_directory=文件å「%sã€å·²ä½œä¸ºç›®å½•å在æ¤ä»“库ä¸å˜åœ¨ã€‚ +editor.file_modifying_no_longer_exists=æ£åœ¨ä¿®æ”¹çš„æ–‡ä»¶ã€Œ%sã€å·²ä¸å˜åœ¨äºŽæ¤ä»“库。 editor.file_changed_while_editing=文件内容在您进行编辑时已ç»å‘生å˜åŠ¨ã€‚<a target="_blank" rel="noopener noreferrer" href="%s">å•击æ¤å¤„</a> 查看å˜åŠ¨çš„å…·ä½“å†…å®¹ï¼Œæˆ–è€… <strong>冿¬¡æäº¤</strong> 覆盖已å‘生的å˜åŠ¨ã€‚ editor.file_already_exists=æ¤ä»“库已ç»å˜åœ¨å为「%sã€çš„æ–‡ä»¶ã€‚ editor.commit_id_not_matching=æäº¤ ID 与您开始编辑时的 ID ä¸åŒ¹é…。请æäº¤åˆ°è¡¥ä¸åˆ†æ”¯ç„¶åŽåˆå¹¶ã€‚ @@ -1392,7 +1397,15 @@ editor.user_no_push_to_branch=用户ä¸èƒ½æŽ¨é€åˆ°åˆ†æ”¯ editor.require_signed_commit=分支需è¦ç¾åæäº¤ editor.cherry_pick=拣选æäº¤ %s 到: editor.revert=å°† %s 还原到: +editor.failed_to_commit=æäº¤æ›´æ”¹å¤±è´¥ã€‚ +editor.failed_to_commit_summary=错误信æ¯ï¼š +editor.fork_create=派生仓库å‘èµ·è¯·æ±‚å˜æ›´ +editor.fork_create_description=您ä¸èƒ½ç›´æŽ¥ç¼–辑æ¤ä»“库。您å¯ä»¥ä»Žæ¤ä»“库派生,进行编辑并创建一个拉å–请求。 +editor.fork_edit_description=您ä¸èƒ½ç›´æŽ¥ç¼–辑æ¤ä»“库。 更改将写入您的派生仓库 <b>%s</b>,以便您å¯ä»¥åˆ›å»ºä¸€ä¸ªæ‹‰å–请求。 +editor.fork_not_editable=ä½ å·²ç»æ´¾ç”Ÿäº†è¿™ä¸ªä»“åº“ï¼Œä½†æ˜¯ä½ çš„åˆ†å‰æ˜¯ä¸å¯ç¼–辑的。 +editor.fork_failed_to_push_branch=推é€åˆ†æ”¯ %s 到仓库失败。 +editor.fork_branch_exists=分支 "%s" å·²å˜åœ¨äºŽæ‚¨çš„æ´¾ç”Ÿä»“库ä¸ï¼Œè¯·é€‰æ‹©ä¸€ä¸ªæ–°çš„分支å称。 commits.desc=æµè§ˆä»£ç ä¿®æ”¹åŽ†å² commits.commits=æ¬¡ä»£ç æäº¤ @@ -1714,6 +1727,8 @@ issues.remove_time_estimate_at=åˆ é™¤é¢„ä¼°æ—¶é—´ %s issues.time_estimate_invalid=é¢„è®¡æ—¶é—´æ ¼å¼æ— 效 issues.start_tracking_history=`开始工作 %s` issues.tracker_auto_close=当æ¤å·¥å•关闿—¶ï¼Œè‡ªåŠ¨åœæ¢è®¡æ—¶å™¨ +issues.stopwatch_already_stopped=æ¤å·¥å•的计时器已ç»åœæ¢ +issues.stopwatch_already_created=æ¤å·¥å•的计时器已ç»å˜åœ¨ issues.tracking_already_started=`您已ç»å¼€å§‹å¯¹ <a href="%s">å¦ä¸€ä¸ªå·¥å•</a> 进行时间跟踪ï¼` issues.stop_tracking=åœæ¢è®¡æ—¶å™¨ issues.stop_tracking_history=工作 <b>%[1]s</b> 于 %[2]s åœæ¢ @@ -1955,6 +1970,7 @@ pulls.cmd_instruction_checkout_title=检出 pulls.cmd_instruction_checkout_desc=ä»Žæ‚¨çš„ä»“åº“ä¸æ£€å‡ºä¸€ä¸ªæ–°çš„åˆ†æ”¯å¹¶æµ‹è¯•å˜æ›´ã€‚ pulls.cmd_instruction_merge_title=åˆå¹¶ pulls.cmd_instruction_merge_desc=åˆå¹¶å˜æ›´å¹¶æ›´æ–°åˆ° Gitea 上 +pulls.cmd_instruction_merge_warning=è¦å‘Šï¼šæ¤æ“作ä¸èƒ½åˆå¹¶è¯¥åˆå¹¶è¯·æ±‚ï¼Œå› ä¸ºã€Œè‡ªåŠ¨æ£€æµ‹æ‰‹åŠ¨åˆå¹¶ã€æœªå¯ç”¨ pulls.clear_merge_message=清除åˆå¹¶ä¿¡æ¯ pulls.clear_merge_message_hint=清除åˆå¹¶æ¶ˆæ¯åªä¼šåˆ 除æäº¤æ¶ˆæ¯å†…容,并ä¿ç•™ç”Ÿæˆçš„ Git é™„åŠ å†…å®¹ï¼Œå¦‚ã€ŒCo-Authored-By…ã€ã€‚ @@ -2150,6 +2166,7 @@ settings.collaboration.write=å¯å†™æƒé™ settings.collaboration.read=å¯è¯»æƒé™ settings.collaboration.owner=所有者 settings.collaboration.undefined=未定义 +settings.collaboration.per_unit=å•å…ƒæƒé™ settings.hooks=Web é’©å settings.githooks=ç®¡ç† Git é’©å settings.basic_settings=基本设置 @@ -2368,6 +2385,7 @@ settings.event_repository=仓库 settings.event_repository_desc=åˆ›å»ºæˆ–åˆ é™¤ä»“åº“ settings.event_header_issue=å·¥å•事件 settings.event_issues=å·¥å• +settings.event_issues_desc=å·¥å•已打开ã€å·²å…³é—ã€å·²é‡æ–°æ‰“开或已编辑。 settings.event_issue_assign=å·¥å•已指派 settings.event_issue_assign_desc=å·¥å•å·²æŒ‡æ´¾æˆ–å–æ¶ˆæŒ‡æ´¾ã€‚ settings.event_issue_label=å·¥å•å¢žåˆ æ ‡ç¾ @@ -2378,6 +2396,7 @@ settings.event_issue_comment=å·¥å•评论 settings.event_issue_comment_desc=å·¥å•评论已创建ã€ç¼–è¾‘æˆ–åˆ é™¤ã€‚ settings.event_header_pull_request=åˆå¹¶è¯·æ±‚事件 settings.event_pull_request=åˆå¹¶è¯·æ±‚ +settings.event_pull_request_desc=åˆå¹¶è¯·æ±‚已打开ã€å…³é—ã€é‡æ–°æ‰“开或编辑。 settings.event_pull_request_assign=åˆå¹¶è¯·æ±‚已指派 settings.event_pull_request_assign_desc=åˆå¹¶è¯·æ±‚å·²æŒ‡æ´¾æˆ–å–æ¶ˆæŒ‡æ´¾ã€‚ settings.event_pull_request_label=åˆå¹¶è¯·æ±‚å¢žåˆ æ ‡ç¾ @@ -2395,6 +2414,8 @@ settings.event_pull_request_review_request_desc=åˆå¹¶è¯·æ±‚è¯„å®¡å·²è¯·æ±‚æˆ–å· settings.event_pull_request_approvals=åˆå¹¶è¯·æ±‚批准 settings.event_pull_request_merge=åˆå¹¶è¯·æ±‚åˆå¹¶ settings.event_header_workflow=工作æµç¨‹äº‹ä»¶ +settings.event_workflow_run=工作æµè¿è¡Œ +settings.event_workflow_run_desc=Gitea 工作æµé˜Ÿåˆ—ä¸ã€ç‰å¾…ä¸ã€æ£åœ¨è¿›è¡Œæˆ–已完æˆçš„任务。 settings.event_workflow_job=工作æµä»»åŠ¡ settings.event_workflow_job_desc=Gitea 工作æµé˜Ÿåˆ—ä¸ã€ç‰å¾…ä¸ã€æ£åœ¨è¿›è¡Œæˆ–已完æˆçš„任务。 settings.event_package=软件包 @@ -2773,7 +2794,7 @@ error.broken_git_hook=æ¤ä»“库的 Git é’©å似乎已æŸå。 请按照 <a tar [graphs] component_loading=æ£åœ¨åŠ è½½ %s... component_loading_failed=æ— æ³•åŠ è½½ %s -component_loading_info=è¿™å¯èƒ½éœ€è¦ä¸€ç‚¹â€¦ +component_loading_info=è¿™å¯èƒ½éœ€è¦ä¸€ç‚¹æ—¶é—´â€¦ component_failed_to_load=æ„外的错误å‘生了。 code_frequency.what=代ç 频率 contributors.what=贡献 @@ -2802,6 +2823,7 @@ team_permission_desc=æƒé™ team_unit_desc=å…许访问仓库å•å…ƒ team_unit_disabled=(å·²ç¦ç”¨) +form.name_been_taken=组织å称「%sã€å·²ç»è¢«å 用。 form.name_reserved=组织å称「%sã€æ˜¯ä¿ç•™çš„。 form.name_pattern_not_allowed=组织åä¸ä¸å…许使用「%sã€æ ¼å¼ã€‚ form.create_org_not_allowed=æ¤è´¦å·ç¦æ¢åˆ›å»ºç»„织 @@ -2824,12 +2846,27 @@ settings.visibility.private_shortname=ç§æœ‰ settings.update_settings=更新组织设置 settings.update_setting_success=组织设置已更新。 +settings.rename=修改组织åç§° +settings.rename_desc=更改组织åç§°åŒæ—¶ä¼šæ›´æ”¹ç»„织的 URL 地å€å¹¶é‡Šæ”¾æ—§çš„å称。 +settings.rename_success=组织 %[1]s å·²æˆåŠŸé‡å‘½å为 %[2]s。 +settings.rename_no_change=组织å称没有å˜åŒ–。 +settings.rename_new_org_name=新组织åç§° +settings.rename_failed=由于内部错误,é‡å‘½å组织失败 +settings.rename_notices_1=æ¤æ“作 <strong>æ— æ³•</strong> 被回滚。 +settings.rename_notices_2=在被人使用å‰ï¼Œæ—§å称将会被é‡å®šå‘。 settings.update_avatar_success=组织头åƒå·²ç»æ›´æ–°ã€‚ settings.delete=åˆ é™¤ç»„ç»‡ settings.delete_account=åˆ é™¤å½“å‰ç»„织 -settings.delete_prompt=åˆ é™¤æ“作会永久清除该组织的信æ¯ï¼Œå¹¶ä¸” <strong>ä¸å¯æ¢å¤</strong>ï¼ +settings.delete_prompt=åˆ é™¤æ“作会永久清除该组织的信æ¯ï¼Œå¹¶ä¸” <strong>æ— æ³•</strong> æ¢å¤ï¼ +settings.name_confirm=输入组织å称以确认: +settings.delete_notices_1=æ¤æ“作 <strong>æ— æ³•</strong> 被回滚。 +settings.delete_notices_2=æ¤æ“ä½œå°†æ°¸ä¹…åˆ é™¤ <strong>%s</strong> 的所有<strong>仓库</strong>,包括 Git æ•°æ®ã€ å·¥å•ã€è¯„论ã€ç™¾ç§‘å’Œå作者的æ“作æƒé™ã€‚ +settings.delete_notices_3=æ¤æ“ä½œå°†æ°¸ä¹…åˆ é™¤ <strong>%s</strong> 的所有 <strong>软件包</strong>。 +settings.delete_notices_4=æ¤æ“ä½œå°†æ°¸ä¹…åˆ é™¤ <strong>%s</strong> 的所有 <strong>项目</strong>。 settings.confirm_delete_account=ç¡®è®¤åˆ é™¤ç»„ç»‡ +settings.delete_failed=ç”±äºŽå†…éƒ¨é”™è¯¯ï¼Œåˆ é™¤ç»„ç»‡å¤±è´¥ +settings.delete_successful=组织 <b>%s</b> å·²æˆåŠŸåˆ é™¤ã€‚ settings.hooks_desc=在æ¤å¤„æ·»åŠ çš„ Web é’©å将会应用到该组织下的 <strong>所有仓库</strong>。 settings.labels_desc=æ·»åŠ èƒ½å¤Ÿè¢«è¯¥ç»„ç»‡ä¸‹çš„ <strong>所有仓库</strong> 的工å•ä½¿ç”¨çš„æ ‡ç¾ã€‚ @@ -3720,8 +3757,8 @@ none=还没有密钥。 ; These keys are also for "edit secret", the keys are kept as-is to avoid unnecessary re-translation creation.description=组织æè¿° creation.name_placeholder=ä¸åŒºåˆ†å¤§å°å†™ï¼Œä»…é™å—æ¯æ•°å—或下划线且ä¸èƒ½ä»¥ GITEA_ 或 GITHUB_ 开头 -creation.value_placeholder=输入任何内容,开头和结尾的空白将会被忽略。 -creation.description_placeholder=è¾“å…¥ç®€çŸæè¿°ï¼ˆå¯é€‰ï¼‰ã€‚ +creation.value_placeholder=输入任何内容,开头和结尾的空白将会被忽略 +creation.description_placeholder=è¾“å…¥ç®€çŸæè¿°ï¼ˆå¯é€‰ï¼‰ save_success=密钥「%sã€ä¿å˜æˆåŠŸã€‚ save_failed=密钥ä¿å˜å¤±è´¥ã€‚ @@ -3806,6 +3843,7 @@ runs.no_runs=工作æµå°šæœªè¿è¡Œè¿‡ã€‚ runs.empty_commit_message=(空白的æäº¤æ¶ˆæ¯ï¼‰ runs.expire_log_message=旧的日志已清除。 runs.delete=åˆ é™¤å·¥ä½œæµè¿è¡Œ +runs.cancel=å–æ¶ˆå·¥ä½œæµè¿è¡Œ runs.delete.description=æ‚¨ç¡®å®šè¦æ°¸ä¹…åˆ é™¤æ¤å·¥ä½œæµè¿è¡Œå—ï¼Ÿæ¤æ“ä½œæ— æ³•æ’¤æ¶ˆã€‚ runs.not_done=æ¤å·¥ä½œæµè¿è¡Œå°šæœªå®Œæˆã€‚ runs.view_workflow_file=æŸ¥çœ‹å·¥ä½œæµæ–‡ä»¶ diff --git a/package-lock.json b/package-lock.json index 6356a04365..8361199086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,6 @@ "dropzone": "6.0.0-beta.2", "easymde": "2.20.0", "esbuild-loader": "4.3.0", - "escape-goat": "4.0.0", "fast-glob": "3.3.3", "htmx.org": "2.0.6", "idiomorph": "0.7.3", @@ -40,6 +39,7 @@ "minimatch": "10.0.2", "monaco-editor": "0.52.2", "monaco-editor-webpack-plugin": "7.1.0", + "online-3d-viewer": "0.16.0", "pdfobject": "2.3.1", "perfect-debounce": "1.0.0", "postcss": "8.5.5", @@ -2026,6 +2026,16 @@ "vue": "^3.2.29" } }, + "node_modules/@simonwep/pickr": { + "version": "1.9.0", + "resolved": "https://registry.npmmirror.com/@simonwep/pickr/-/pickr-1.9.0.tgz", + "integrity": "sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==", + "license": "MIT", + "dependencies": { + "core-js": "3.32.2", + "nanopop": "2.3.0" + } + }, "node_modules/@stoplight/better-ajv-errors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", @@ -5337,6 +5347,17 @@ "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.32.2", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.32.2.tgz", + "integrity": "sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.43.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.43.0.tgz", @@ -6541,18 +6562,6 @@ "node": ">=6" } }, - "node_modules/escape-goat": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", - "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -7721,6 +7730,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -10285,6 +10300,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanopop": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.3.0.tgz", + "integrity": "sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", @@ -10525,6 +10546,17 @@ "wrappy": "1" } }, + "node_modules/online-3d-viewer": { + "version": "0.16.0", + "resolved": "https://registry.npmmirror.com/online-3d-viewer/-/online-3d-viewer-0.16.0.tgz", + "integrity": "sha512-Mcmo41TM3K+svlMDRH8ySKSY2e8s7Sssdb5U9LV3gkFKVWGGuS304Vk5gqxopAJbE72DpsC67Ve3YNtcAuROwQ==", + "license": "MIT", + "dependencies": { + "@simonwep/pickr": "1.9.0", + "fflate": "0.8.2", + "three": "0.176.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13193,6 +13225,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.176.0", + "resolved": "https://registry.npmmirror.com/three/-/three-0.176.0.tgz", + "integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==", + "license": "MIT" + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", diff --git a/package.json b/package.json index 5595e55fa5..fc620bc986 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "dropzone": "6.0.0-beta.2", "easymde": "2.20.0", "esbuild-loader": "4.3.0", - "escape-goat": "4.0.0", "fast-glob": "3.3.3", "htmx.org": "2.0.6", "idiomorph": "0.7.3", @@ -39,6 +38,7 @@ "minimatch": "10.0.2", "monaco-editor": "0.52.2", "monaco-editor-webpack-plugin": "7.1.0", + "online-3d-viewer": "0.16.0", "pdfobject": "2.3.1", "perfect-debounce": "1.0.0", "postcss": "8.5.5", diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index f65c4b99ff..878e0f9945 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -467,7 +467,9 @@ func CommonRoutes() *web.Router { g.MatchPath("HEAD", "/<group:*>/repodata/<filename>", rpm.CheckRepositoryFileExistence) g.MatchPath("GET", "/<group:*>/repodata/<filename>", rpm.GetRepositoryFile) g.MatchPath("PUT", "/<group:*>/upload", reqPackageAccess(perm.AccessModeWrite), rpm.UploadPackageFile) + // this URL pattern is only used internally in the RPM index, it is generated by us, the filename part is not really used (can be anything) g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>", rpm.DownloadPackageFile) + g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>/<filename>", rpm.DownloadPackageFile) g.MatchPath("DELETE", "/<group:*>/package/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), rpm.DeletePackageFile) }, reqPackageAccess(perm.AccessModeRead)) diff --git a/routers/api/v1/admin/org.go b/routers/api/v1/admin/org.go index c7a4ae8419..c3473372f2 100644 --- a/routers/api/v1/admin/org.go +++ b/routers/api/v1/admin/org.go @@ -29,7 +29,7 @@ func CreateOrg(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of the user that will own the created organization + // description: username of the user who will own the created organization // type: string // required: true // - name: organization diff --git a/routers/api/v1/admin/repo.go b/routers/api/v1/admin/repo.go index c119d5390a..12a78c9c4b 100644 --- a/routers/api/v1/admin/repo.go +++ b/routers/api/v1/admin/repo.go @@ -22,7 +22,7 @@ func CreateRepo(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of the user. This user will own the created repository + // description: username of the user who will own the created repository // type: string // required: true // - name: repository diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index c29f4e4622..8a267cc418 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -175,7 +175,7 @@ func EditUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to edit + // description: username of the user whose data is to be edited // type: string // required: true // - name: body @@ -272,7 +272,7 @@ func DeleteUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to delete + // description: username of the user to delete // type: string // required: true // - name: purge @@ -328,7 +328,7 @@ func CreatePublicKey(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of the user + // description: username of the user who is to receive a public key // type: string // required: true // - name: key @@ -358,7 +358,7 @@ func DeleteUserPublicKey(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose public key is to be deleted // type: string // required: true // - name: id @@ -405,7 +405,7 @@ func SearchUsers(ctx *context.APIContext) { // format: int64 // - name: login_name // in: query - // description: user's login name to search for + // description: identifier of the user, provided by the external authenticator // type: string // - name: page // in: query @@ -456,7 +456,7 @@ func RenameUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: existing username of user + // description: current username of the user // type: string // required: true // - name: body diff --git a/routers/api/v1/admin/user_badge.go b/routers/api/v1/admin/user_badge.go index 6d9665a72b..ce32f455b0 100644 --- a/routers/api/v1/admin/user_badge.go +++ b/routers/api/v1/admin/user_badge.go @@ -22,7 +22,7 @@ func ListUserBadges(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose badges are to be listed // type: string // required: true // responses: @@ -53,7 +53,7 @@ func AddUserBadges(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user to whom a badge is to be added // type: string // required: true // - name: body @@ -87,7 +87,7 @@ func DeleteUserBadges(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose badge is to be deleted // type: string // required: true // - name: body diff --git a/routers/api/v1/org/block.go b/routers/api/v1/org/block.go index 69a5222a20..6b2f3dc615 100644 --- a/routers/api/v1/org/block.go +++ b/routers/api/v1/org/block.go @@ -47,7 +47,7 @@ func CheckUserBlock(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: user to check + // description: username of the user to check // type: string // required: true // responses: @@ -71,7 +71,7 @@ func BlockUser(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: user to block + // description: username of the user to block // type: string // required: true // - name: note @@ -101,7 +101,7 @@ func UnblockUser(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: user to unblock + // description: username of the user to unblock // type: string // required: true // responses: diff --git a/routers/api/v1/org/member.go b/routers/api/v1/org/member.go index a1875a7886..1c12b0cc94 100644 --- a/routers/api/v1/org/member.go +++ b/routers/api/v1/org/member.go @@ -133,7 +133,7 @@ func IsMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user to check for an organization membership // type: string // required: true // responses: @@ -186,7 +186,7 @@ func IsPublicMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user to check for a public organization membership // type: string // required: true // responses: @@ -240,7 +240,7 @@ func PublicizeMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user whose membership is to be publicized // type: string // required: true // responses: @@ -282,7 +282,7 @@ func ConcealMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user whose membership is to be concealed // type: string // required: true // responses: @@ -324,7 +324,7 @@ func DeleteMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user to remove from the organization // type: string // required: true // responses: diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index adb117c4e8..05744ba155 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -82,7 +82,7 @@ func ListUserOrgs(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose organizations are to be listed // type: string // required: true // - name: page @@ -112,7 +112,7 @@ func GetUserOrgsPermissions(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose permissions are to be obtained // type: string // required: true // - name: org diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index 71c21f2dde..1a1710750a 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -426,7 +426,7 @@ func GetTeamMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the member to list + // description: username of the user whose data is to be listed // type: string // required: true // responses: @@ -467,7 +467,7 @@ func AddTeamMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user to add + // description: username of the user to add to a team // type: string // required: true // responses: @@ -509,7 +509,7 @@ func RemoveTeamMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user to remove + // description: username of the user to remove from a team // type: string // required: true // responses: diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go index d1652c1d51..c2c10cc695 100644 --- a/routers/api/v1/repo/collaborators.go +++ b/routers/api/v1/repo/collaborators.go @@ -93,7 +93,7 @@ func IsCollaborator(ctx *context.APIContext) { // required: true // - name: collaborator // in: path - // description: username of the collaborator + // description: username of the user to check for being a collaborator // type: string // required: true // responses: @@ -145,7 +145,7 @@ func AddOrUpdateCollaborator(ctx *context.APIContext) { // required: true // - name: collaborator // in: path - // description: username of the collaborator to add + // description: username of the user to add or update as a collaborator // type: string // required: true // - name: body @@ -264,7 +264,7 @@ func GetRepoPermissions(ctx *context.APIContext) { // required: true // - name: collaborator // in: path - // description: username of the collaborator + // description: username of the collaborator whose permissions are to be obtained // type: string // required: true // responses: diff --git a/routers/api/v1/repo/issue_subscription.go b/routers/api/v1/repo/issue_subscription.go index 21e549496d..c89f228a06 100644 --- a/routers/api/v1/repo/issue_subscription.go +++ b/routers/api/v1/repo/issue_subscription.go @@ -43,7 +43,7 @@ func AddIssueSubscription(ctx *context.APIContext) { // required: true // - name: user // in: path - // description: user to subscribe + // description: username of the user to subscribe the issue to // type: string // required: true // responses: @@ -87,7 +87,7 @@ func DelIssueSubscription(ctx *context.APIContext) { // required: true // - name: user // in: path - // description: user witch unsubscribe + // description: username of the user to unsubscribe from an issue // type: string // required: true // responses: diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index dd6abf94c6..171da272cc 100644 --- a/routers/api/v1/repo/issue_tracked_time.go +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -405,7 +405,7 @@ func ListTrackedTimesByUser(ctx *context.APIContext) { // required: true // - name: user // in: path - // description: username of user + // description: username of the user whose tracked times are to be listed // type: string // required: true // responses: diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index 7201010161..6f1053e7ac 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -30,7 +30,7 @@ func ListAccessTokens(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of to user whose access tokens are to be listed // type: string // required: true // - name: page @@ -83,7 +83,7 @@ func CreateAccessToken(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose token is to be created // required: true // type: string // - name: body @@ -149,7 +149,7 @@ func DeleteAccessToken(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose token is to be deleted // type: string // required: true // - name: token diff --git a/routers/api/v1/user/block.go b/routers/api/v1/user/block.go index 7231e9add7..8365188f60 100644 --- a/routers/api/v1/user/block.go +++ b/routers/api/v1/user/block.go @@ -37,7 +37,7 @@ func CheckUserBlock(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: user to check + // description: username of the user to check // type: string // required: true // responses: @@ -56,7 +56,7 @@ func BlockUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: user to block + // description: username of the user to block // type: string // required: true // - name: note @@ -81,7 +81,7 @@ func UnblockUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: user to unblock + // description: username of the user to unblock // type: string // required: true // responses: diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go index 0d0c0be7e0..339b994af4 100644 --- a/routers/api/v1/user/follower.go +++ b/routers/api/v1/user/follower.go @@ -67,7 +67,7 @@ func ListFollowers(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose followers are to be listed // type: string // required: true // - name: page @@ -131,7 +131,7 @@ func ListFollowing(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose followed users are to be listed // type: string // required: true // - name: page @@ -167,7 +167,7 @@ func CheckMyFollowing(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of followed user + // description: username of the user to check for authenticated followers // type: string // required: true // responses: @@ -187,12 +187,12 @@ func CheckFollowing(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of following user + // description: username of the following user // type: string // required: true // - name: target // in: path - // description: username of followed user + // description: username of the followed user // type: string // required: true // responses: @@ -216,7 +216,7 @@ func Follow(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to follow + // description: username of the user to follow // type: string // required: true // responses: @@ -246,7 +246,7 @@ func Unfollow(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to unfollow + // description: username of the user to unfollow // type: string // required: true // responses: diff --git a/routers/api/v1/user/gpg_key.go b/routers/api/v1/user/gpg_key.go index b76bd8a1ee..9ec4d2c938 100644 --- a/routers/api/v1/user/gpg_key.go +++ b/routers/api/v1/user/gpg_key.go @@ -53,7 +53,7 @@ func ListGPGKeys(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose GPG key list is to be obtained // type: string // required: true // - name: page diff --git a/routers/api/v1/user/key.go b/routers/api/v1/user/key.go index 628f5d6cac..aa69245e49 100644 --- a/routers/api/v1/user/key.go +++ b/routers/api/v1/user/key.go @@ -136,7 +136,7 @@ func ListPublicKeys(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose public keys are to be listed // type: string // required: true // - name: fingerprint diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go index 47efeb2a2b..6d0129681e 100644 --- a/routers/api/v1/user/repo.go +++ b/routers/api/v1/user/repo.go @@ -62,7 +62,7 @@ func ListUserRepos(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose owned repos are to be listed // type: string // required: true // - name: page diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index 4b0cb45d67..ee5d63063b 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -50,7 +50,7 @@ func GetStarredRepos(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose starred repos are to be listed // type: string // required: true // - name: page diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 2b98fb5ac7..6de1125c40 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -110,7 +110,7 @@ func GetInfo(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to get + // description: username of the user whose data is to be listed // type: string // required: true // responses: @@ -151,7 +151,7 @@ func GetUserHeatmapData(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to get + // description: username of the user whose heatmap is to be obtained // type: string // required: true // responses: @@ -177,7 +177,7 @@ func ListUserActivityFeeds(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose activity feeds are to be listed // type: string // required: true // - name: only-performed-by diff --git a/routers/api/v1/user/watch.go b/routers/api/v1/user/watch.go index 76d7c81793..844eac2c67 100644 --- a/routers/api/v1/user/watch.go +++ b/routers/api/v1/user/watch.go @@ -49,7 +49,7 @@ func GetWatchedRepos(ctx *context.APIContext) { // - name: username // type: string // in: path - // description: username of the user + // description: username of the user whose watched repos are to be listed // required: true // - name: page // in: query diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index 9aee3d6a86..2a5ac10282 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -244,7 +244,7 @@ func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.Read return nil, nil, nil } - if fInfo.isLFSFile { + if fInfo.isLFSFile() { lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) if err != nil { _ = dataRc.Close() @@ -298,7 +298,7 @@ func EditFile(ctx *context.Context) { ctx.Data["FileSize"] = fInfo.fileSize // Only some file types are editable online as text. - if fInfo.isLFSFile { + if fInfo.isLFSFile() { ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") } else if !fInfo.st.IsRepresentableAsText() { ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 2897652d51..d4458ed19e 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -443,6 +443,10 @@ func ViewPullMergeBox(ctx *context.Context) { preparePullViewPullInfo(ctx, issue) preparePullViewReviewAndMerge(ctx, issue) ctx.Data["PullMergeBoxReloading"] = issue.PullRequest.IsChecking() + + // TODO: it should use a dedicated struct to render the pull merge box, to make sure all data is prepared correctly + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) ctx.HTML(http.StatusOK, tplPullMergeBox) } diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go index bbbb99dc89..af6708e841 100644 --- a/routers/web/repo/setting/lfs.go +++ b/routers/web/repo/setting/lfs.go @@ -267,8 +267,10 @@ func LFSFileGet(ctx *context.Context) { buf = buf[:n] st := typesniffer.DetectContentType(buf) + // FIXME: there is no IsPlainText set, but template uses it ctx.Data["IsTextFile"] = st.IsText() ctx.Data["FileSize"] = meta.Size + // FIXME: the last field is the URL-base64-encoded filename, it should not be "direct" ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct") switch { case st.IsRepresentableAsText(): @@ -309,8 +311,6 @@ func LFSFileGet(ctx *context.Context) { } ctx.Data["LineNums"] = gotemplate.HTML(output.String()) - case st.IsPDF(): - ctx.Data["IsPDFFile"] = true case st.IsVideo(): ctx.Data["IsVideoFile"] = true case st.IsAudio(): diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go index 0248a0627b..7d7f5a1473 100644 --- a/routers/web/repo/treelist.go +++ b/routers/web/repo/treelist.go @@ -6,6 +6,7 @@ package repo import ( "html/template" "net/http" + "path" "strings" pull_model "code.gitea.io/gitea/models/pull" @@ -111,7 +112,7 @@ func transformDiffTreeForWeb(renderedIconPool *fileicon.RenderedIconPool, diffTr item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status} item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed item.NameHash = git.HashFilePathForWebUI(item.FullName) - item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{FullName: file.HeadPath, EntryMode: file.HeadMode}) + item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{BaseName: path.Base(file.HeadPath), EntryMode: file.HeadMode}) switch file.HeadMode { case git.EntryModeTree: diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index f0d90f9533..773919c054 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "net/url" + "path" "strings" "time" @@ -59,60 +60,63 @@ const ( ) type fileInfo struct { - isTextFile bool - isLFSFile bool - fileSize int64 - lfsMeta *lfs.Pointer - st typesniffer.SniffedType + fileSize int64 + lfsMeta *lfs.Pointer + st typesniffer.SniffedType } -func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, io.ReadCloser, *fileInfo, error) { - dataRc, err := blob.DataAsync() +func (fi *fileInfo) isLFSFile() bool { + return fi.lfsMeta != nil && fi.lfsMeta.Oid != "" +} + +func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) (buf []byte, dataRc io.ReadCloser, fi *fileInfo, err error) { + dataRc, err = blob.DataAsync() if err != nil { return nil, nil, nil, err } - buf := make([]byte, 1024) + const prefetchSize = lfs.MetaFileMaxSize + + buf = make([]byte, prefetchSize) n, _ := util.ReadAtMost(dataRc, buf) buf = buf[:n] - st := typesniffer.DetectContentType(buf) - isTextFile := st.IsText() + fi = &fileInfo{fileSize: blob.Size(), st: typesniffer.DetectContentType(buf)} // FIXME: what happens when README file is an image? - if !isTextFile || !setting.LFS.StartServer { - return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + if !fi.st.IsText() || !setting.LFS.StartServer { + return buf, dataRc, fi, nil } pointer, _ := lfs.ReadPointerFromBuffer(buf) - if !pointer.IsValid() { // fallback to plain file - return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + if !pointer.IsValid() { // fallback to a plain file + return buf, dataRc, fi, nil } meta, err := git_model.GetLFSMetaObjectByOid(ctx, repoID, pointer.Oid) - if err != nil { // fallback to plain file + if err != nil { // fallback to a plain file log.Warn("Unable to access LFS pointer %s in repo %d: %v", pointer.Oid, repoID, err) - return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + return buf, dataRc, fi, nil } - dataRc.Close() - + // close the old dataRc and open the real LFS target + _ = dataRc.Close() dataRc, err = lfs.ReadMetaObject(pointer) if err != nil { return nil, nil, nil, err } - buf = make([]byte, 1024) + buf = make([]byte, prefetchSize) n, err = util.ReadAtMost(dataRc, buf) if err != nil { - dataRc.Close() - return nil, nil, nil, err + _ = dataRc.Close() + return nil, nil, fi, err } buf = buf[:n] - - st = typesniffer.DetectContentType(buf) - - return buf, dataRc, &fileInfo{st.IsText(), true, meta.Size, &meta.Pointer, st}, nil + fi.st = typesniffer.DetectContentType(buf) + fi.fileSize = blob.Size() + fi.lfsMeta = &meta.Pointer + return buf, dataRc, fi, nil } func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { @@ -257,7 +261,9 @@ func prepareDirectoryFileIcons(ctx *context.Context, files []git.CommitInfo) { renderedIconPool := fileicon.NewRenderedIconPool() fileIcons := map[string]template.HTML{} for _, f := range files { - fileIcons[f.Entry.Name()] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFromGitTreeEntry(f.Entry)) + fullPath := path.Join(ctx.Repo.TreePath, f.Entry.Name()) + entryInfo := fileicon.EntryInfoFromGitTreeEntry(ctx.Repo.Commit, fullPath, f.Entry) + fileIcons[f.Entry.Name()] = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo) } fileIcons[".."] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder()) ctx.Data["FileIcons"] = fileIcons diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 5606a8e6ec..2d5bddd939 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -23,6 +23,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" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" @@ -40,7 +41,128 @@ func prepareLatestCommitInfo(ctx *context.Context) bool { return loadLatestCommitData(ctx, commit) } -func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { +func prepareFileViewLfsAttrs(ctx *context.Context) (*attribute.Attributes, bool) { + attrsMap, err := attribute.CheckAttributes(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, attribute.CheckAttributeOpts{ + Filenames: []string{ctx.Repo.TreePath}, + Attributes: []string{attribute.LinguistGenerated, attribute.LinguistVendored, attribute.LinguistLanguage, attribute.GitlabLanguage}, + }) + if err != nil { + ctx.ServerError("attribute.CheckAttributes", err) + return nil, false + } + attrs := attrsMap[ctx.Repo.TreePath] + if attrs == nil { + // this case shouldn't happen, just in case. + setting.PanicInDevOrTesting("no attributes found for %s", ctx.Repo.TreePath) + attrs = attribute.NewAttributes() + } + ctx.Data["IsVendored"], ctx.Data["IsGenerated"] = attrs.GetVendored().Value(), attrs.GetGenerated().Value() + return attrs, true +} + +func handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte, utf8Reader io.Reader) bool { + markupType := markup.DetectMarkupTypeByFileName(filename) + if markupType == "" { + markupType = markup.DetectRendererType(filename, sniffedType, prefetchBuf) + } + if markupType == "" { + return false + } + + ctx.Data["HasSourceRenderedToggle"] = true + + if ctx.FormString("display") == "source" { + return false + } + + ctx.Data["MarkupType"] = markupType + metas := ctx.Repo.Repository.ComposeRepoFileMetas(ctx) + metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL() + rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ + CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), + CurrentTreePath: path.Dir(ctx.Repo.TreePath), + }). + WithMarkupType(markupType). + WithRelativePath(ctx.Repo.TreePath). + WithMetas(metas) + + var err error + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, utf8Reader) + if err != nil { + ctx.ServerError("Render", err) + return true + } + // to prevent iframe from loading third-party url + ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") + return true +} + +func handleFileViewRenderSource(ctx *context.Context, filename string, attrs *attribute.Attributes, fInfo *fileInfo, utf8Reader io.Reader) bool { + if ctx.FormString("display") == "rendered" || !fInfo.st.IsRepresentableAsText() { + return false + } + + if !fInfo.st.IsText() { + if ctx.FormString("display") == "" { + // not text but representable as text, e.g. SVG + // since there is no "display" is specified, let other renders to handle + return false + } + ctx.Data["HasSourceRenderedToggle"] = true + } + + buf, _ := io.ReadAll(utf8Reader) + // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html + // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; + // Gitea uses the definition (like most modern editors): + // empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines; + // When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL. + // To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines. + // This NumLines is only used for the display on the UI: "xxx lines" + if len(buf) == 0 { + ctx.Data["NumLines"] = 0 + } else { + ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1 + } + + language := attrs.GetLanguage().Value() + fileContent, lexerName, err := highlight.File(filename, language, buf) + ctx.Data["LexerName"] = lexerName + if err != nil { + log.Error("highlight.File failed, fallback to plain text: %v", err) + fileContent = highlight.PlainText(buf) + } + status := &charset.EscapeStatus{} + statuses := make([]*charset.EscapeStatus, len(fileContent)) + for i, line := range fileContent { + statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale) + status = status.Or(statuses[i]) + } + ctx.Data["EscapeStatus"] = status + ctx.Data["FileContent"] = fileContent + ctx.Data["LineEscapeStatus"] = statuses + return true +} + +func handleFileViewRenderImage(ctx *context.Context, fInfo *fileInfo, prefetchBuf []byte) bool { + if !fInfo.st.IsImage() { + return false + } + if fInfo.st.IsSvgImage() && !setting.UI.SVG.Enabled { + return false + } + if fInfo.st.IsSvgImage() { + ctx.Data["HasSourceRenderedToggle"] = true + } else { + img, _, err := image.DecodeConfig(bytes.NewReader(prefetchBuf)) + if err == nil { // ignore the error for the formats that are not supported by image.DecodeConfig + ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) + } + } + return true +} + +func prepareFileView(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["IsViewFile"] = true ctx.Data["HideRepoInfo"] = true @@ -86,11 +208,8 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { } } - isDisplayingSource := ctx.FormString("display") == "source" - isDisplayingRendered := !isDisplayingSource - // Don't call any other repository functions depends on git.Repository until the dataRc closed to - // avoid create unnecessary temporary cat file. + // avoid creating an unnecessary temporary cat file. buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob) if err != nil { ctx.ServerError("getFileReader", err) @@ -98,207 +217,62 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { } defer dataRc.Close() - if fInfo.isLFSFile { + if fInfo.isLFSFile() { ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) } - isRepresentableAsText := fInfo.st.IsRepresentableAsText() - if !isRepresentableAsText { - // If we can't show plain text, always try to render. - isDisplayingSource = false - isDisplayingRendered = true + if !prepareFileViewEditorButtons(ctx) { + return } - ctx.Data["IsLFSFile"] = fInfo.isLFSFile + + ctx.Data["IsLFSFile"] = fInfo.isLFSFile() ctx.Data["FileSize"] = fInfo.fileSize - ctx.Data["IsTextFile"] = fInfo.isTextFile - ctx.Data["IsRepresentableAsText"] = isRepresentableAsText - ctx.Data["IsDisplayingSource"] = isDisplayingSource - ctx.Data["IsDisplayingRendered"] = isDisplayingRendered + ctx.Data["IsRepresentableAsText"] = fInfo.st.IsRepresentableAsText() ctx.Data["IsExecutable"] = entry.IsExecutable() + ctx.Data["CanCopyContent"] = fInfo.st.IsRepresentableAsText() || fInfo.st.IsImage() - isTextSource := fInfo.isTextFile || isDisplayingSource - ctx.Data["IsTextSource"] = isTextSource - if isTextSource { - ctx.Data["CanCopyContent"] = true - } - - // Check LFS Lock - lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) - ctx.Data["LFSLock"] = lfsLock - if err != nil { - ctx.ServerError("GetTreePathLock", err) + attrs, ok := prepareFileViewLfsAttrs(ctx) + if !ok { return } - if lfsLock != nil { - u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID) - if err != nil { - ctx.ServerError("GetTreePathLock", err) - return - } - ctx.Data["LFSLockOwner"] = u.Name - ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink() - ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") - } - // read all needed attributes which will be used later - // there should be no performance different between reading 2 or 4 here - attrsMap, err := attribute.CheckAttributes(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, attribute.CheckAttributeOpts{ - Filenames: []string{ctx.Repo.TreePath}, - Attributes: []string{attribute.LinguistGenerated, attribute.LinguistVendored, attribute.LinguistLanguage, attribute.GitlabLanguage}, - }) - if err != nil { - ctx.ServerError("attribute.CheckAttributes", err) - return - } - attrs := attrsMap[ctx.Repo.TreePath] - if attrs == nil { - // this case shouldn't happen, just in case. - setting.PanicInDevOrTesting("no attributes found for %s", ctx.Repo.TreePath) - attrs = attribute.NewAttributes() - } + // TODO: in the future maybe we need more accurate flags, for example: + // * IsRepresentableAsText: some files are text, some are not + // * IsRenderableXxx: some files are rendered by backend "markup" engine, some are rendered by frontend (pdf, 3d) + // * DefaultViewMode: when there is no "display" query parameter, which view mode should be used by default, source or rendered + utf8Reader := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) switch { - case isRepresentableAsText: - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } - - if fInfo.st.IsSvgImage() { - ctx.Data["IsImageFile"] = true - ctx.Data["CanCopyContent"] = true - ctx.Data["HasSourceRenderedToggle"] = true - } - - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) - - shouldRenderSource := ctx.FormString("display") == "source" - readmeExist := util.IsReadmeFileName(blob.Name()) - ctx.Data["ReadmeExist"] = readmeExist - - markupType := markup.DetectMarkupTypeByFileName(blob.Name()) - if markupType == "" { - markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf)) - } - if markupType != "" { - ctx.Data["HasSourceRenderedToggle"] = true - } - if markupType != "" && !shouldRenderSource { - ctx.Data["IsMarkup"] = true - ctx.Data["MarkupType"] = markupType - metas := ctx.Repo.Repository.ComposeRepoFileMetas(ctx) - metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL() - rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), - }). - WithMarkupType(markupType). - WithRelativePath(ctx.Repo.TreePath). - WithMetas(metas) - - ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) - if err != nil { - ctx.ServerError("Render", err) - return - } - // to prevent iframe load third-party url - ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") - } else { - buf, _ := io.ReadAll(rd) - - // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html - // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; - // Gitea uses the definition (like most modern editors): - // empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines; - // When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL. - // To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines. - // This NumLines is only used for the display on the UI: "xxx lines" - if len(buf) == 0 { - ctx.Data["NumLines"] = 0 - } else { - ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1 - } - - language := attrs.GetLanguage().Value() - fileContent, lexerName, err := highlight.File(blob.Name(), language, buf) - ctx.Data["LexerName"] = lexerName - if err != nil { - log.Error("highlight.File failed, fallback to plain text: %v", err) - fileContent = highlight.PlainText(buf) - } - status := &charset.EscapeStatus{} - statuses := make([]*charset.EscapeStatus, len(fileContent)) - for i, line := range fileContent { - statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale) - status = status.Or(statuses[i]) - } - ctx.Data["EscapeStatus"] = status - ctx.Data["FileContent"] = fileContent - ctx.Data["LineEscapeStatus"] = statuses - } - - case fInfo.st.IsPDF(): - ctx.Data["IsPDFFile"] = true + case fInfo.fileSize >= setting.UI.MaxDisplayFileSize: + ctx.Data["IsFileTooLarge"] = true + case handleFileViewRenderMarkup(ctx, entry.Name(), fInfo.st, buf, utf8Reader): + // it also sets ctx.Data["FileContent"] and more + ctx.Data["IsMarkup"] = true + case handleFileViewRenderSource(ctx, entry.Name(), attrs, fInfo, utf8Reader): + // it also sets ctx.Data["FileContent"] and more + ctx.Data["IsDisplayingSource"] = true + case handleFileViewRenderImage(ctx, fInfo, buf): + ctx.Data["IsImageFile"] = true case fInfo.st.IsVideo(): ctx.Data["IsVideoFile"] = true case fInfo.st.IsAudio(): ctx.Data["IsAudioFile"] = true - case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()): - ctx.Data["IsImageFile"] = true - ctx.Data["CanCopyContent"] = true default: - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } - - // TODO: this logic duplicates with "isRepresentableAsText=true", it is not the same as "LFSFileGet" in "lfs.go" - // It is used by "external renders", markupRender will execute external programs to get rendered content. - if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType != "" { - rd := io.MultiReader(bytes.NewReader(buf), dataRc) - ctx.Data["IsMarkup"] = true - ctx.Data["MarkupType"] = markupType - - rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), - }). - WithMarkupType(markupType). - WithRelativePath(ctx.Repo.TreePath) - - ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) - if err != nil { - ctx.ServerError("Render", err) - return - } - } - } - - ctx.Data["IsVendored"], ctx.Data["IsGenerated"] = attrs.GetVendored().Value(), attrs.GetGenerated().Value() - - if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() { - img, _, err := image.DecodeConfig(bytes.NewReader(buf)) - if err == nil { - // There are Image formats go can't decode - // Instead of throwing an error in that case, we show the size only when we can decode - ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) - } + // unable to render anything, show the "view raw" or let frontend handle it } - - prepareToRenderButtons(ctx, lfsLock) } -func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { +func prepareFileViewEditorButtons(ctx *context.Context) bool { // archived or mirror repository, the buttons should not be shown if !ctx.Repo.Repository.CanEnableEditor() { - return + return true } // The buttons should not be shown if it's not a branch if !ctx.Repo.RefFullName.IsBranch() { ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") - return + return true } if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { @@ -306,7 +280,24 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") ctx.Data["CanDeleteFile"] = true ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") - return + return true + } + + lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) + ctx.Data["LFSLock"] = lfsLock + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return false + } + if lfsLock != nil { + u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID) + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return false + } + ctx.Data["LFSLockOwner"] = u.Name + ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink() + ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") } // it's a lfs file and the user is not the owner of the lock @@ -315,4 +306,5 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { ctx.Data["EditFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.edit_this_file")) ctx.Data["CanDeleteFile"] = !isLFSLocked ctx.Data["DeleteFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.delete_this_file")) + return true } diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index 48fa47d738..c7396d44e3 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -143,7 +143,7 @@ func prepareToRenderDirectory(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefFullName.ShortName()) } - subfolder, readmeFile, err := findReadmeFileInEntries(ctx, entries, true) + subfolder, readmeFile, err := findReadmeFileInEntries(ctx, ctx.Repo.TreePath, entries, true) if err != nil { ctx.ServerError("findReadmeFileInEntries", err) return @@ -339,7 +339,7 @@ func prepareToRenderDirOrFile(entry *git.TreeEntry) func(ctx *context.Context) { if entry.IsDir() { prepareToRenderDirectory(ctx) } else { - prepareToRenderFile(ctx, entry) + prepareFileView(ctx, entry) } } } @@ -377,8 +377,8 @@ func prepareHomeTreeSideBarSwitch(ctx *context.Context) { func redirectSrcToRaw(ctx *context.Context) bool { // GitHub redirects a tree path with "?raw=1" to the raw path - // It is useful to embed some raw contents into markdown files, - // then viewing the markdown in "src" path could embed the raw content correctly. + // It is useful to embed some raw contents into Markdown files, + // then viewing the Markdown in "src" path could embed the raw content correctly. if ctx.Repo.TreePath != "" && ctx.FormBool("raw") { ctx.Redirect(ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)) return true @@ -386,6 +386,20 @@ func redirectSrcToRaw(ctx *context.Context) bool { return false } +func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) bool { + if ctx.Repo.TreePath == "" || !ctx.FormBool("follow_symlink") { + return false + } + if treePathEntry.IsLink() { + if res, err := git.EntryFollowLinks(ctx.Repo.Commit, ctx.Repo.TreePath, treePathEntry); err == nil { + redirect := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(res.TargetFullPath) + "?" + ctx.Req.URL.RawQuery + ctx.Redirect(redirect) + return true + } // else: don't handle the links we cannot resolve, so ignore the error + } + return false +} + // Home render repository home page func Home(ctx *context.Context) { if handleRepoHomeFeed(ctx) { @@ -394,6 +408,7 @@ func Home(ctx *context.Context) { if redirectSrcToRaw(ctx) { return } + // Check whether the repo is viewable: not in migration, and the code unit should be enabled // Ideally the "feed" logic should be after this, but old code did so, so keep it as-is. checkHomeCodeViewable(ctx) @@ -424,6 +439,10 @@ func Home(ctx *context.Context) { return } + if redirectFollowSymlink(ctx, entry) { + return + } + // prepare the tree path var treeNames, paths []string branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 4ce22d79db..ba03febff3 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -32,15 +32,7 @@ import ( // entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries() // // FIXME: There has to be a more efficient way of doing this -func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) { - // Create a list of extensions in priority order - // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md - // 2. Txt files - e.g. README.txt - // 3. No extension - e.g. README - exts := append(localizedExtensions(".md", ctx.Locale.Language()), ".txt", "") // sorted by priority - extCount := len(exts) - readmeFiles := make([]*git.TreeEntry, extCount+1) - +func findReadmeFileInEntries(ctx *context.Context, parentDir string, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) { docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/) for _, entry := range entries { if tryWellKnownDirs && entry.IsDir() { @@ -62,16 +54,23 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try docsEntries[2] = entry } } - continue } + } + + // Create a list of extensions in priority order + // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md + // 2. Txt files - e.g. README.txt + // 3. No extension - e.g. README + exts := append(localizedExtensions(".md", ctx.Locale.Language()), ".txt", "") // sorted by priority + extCount := len(exts) + readmeFiles := make([]*git.TreeEntry, extCount+1) + for _, entry := range entries { if i, ok := util.IsReadmeFileExtension(entry.Name(), exts...); ok { - log.Debug("Potential readme file: %s", entry.Name()) + fullPath := path.Join(parentDir, entry.Name()) if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) { if entry.IsLink() { - target, err := entry.FollowLinks() - if err != nil && !git.IsErrSymlinkUnresolved(err) { - return "", nil, err - } else if target != nil && (target.IsExecutable() || target.IsRegular()) { + res, err := git.EntryFollowLinks(ctx.Repo.Commit, fullPath, entry) + if err == nil && (res.TargetEntry.IsExecutable() || res.TargetEntry.IsRegular()) { readmeFiles[i] = entry } } else { @@ -80,6 +79,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try } } } + var readmeFile *git.TreeEntry for _, f := range readmeFiles { if f != nil { @@ -103,7 +103,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try return "", nil, err } - subfolder, readmeFile, err := findReadmeFileInEntries(ctx, childEntries, false) + subfolder, readmeFile, err := findReadmeFileInEntries(ctx, parentDir, childEntries, false) if err != nil && !git.IsErrNotExist(err) { return "", nil, err } @@ -139,46 +139,52 @@ func localizedExtensions(ext, languageCode string) (localizedExts []string) { } func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) { - target := readmeFile - if readmeFile != nil && readmeFile.IsLink() { - target, _ = readmeFile.FollowLinks() - } - if target == nil { - // if findReadmeFile() failed and/or gave us a broken symlink (which it shouldn't) - // simply skip rendering the README + if readmeFile == nil { return } + readmeFullPath := path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name()) + readmeTargetEntry := readmeFile + if readmeFile.IsLink() { + if res, err := git.EntryFollowLinks(ctx.Repo.Commit, readmeFullPath, readmeFile); err == nil { + readmeTargetEntry = res.TargetEntry + } else { + readmeTargetEntry = nil // if we cannot resolve the symlink, we cannot render the readme, ignore the error + } + } + if readmeTargetEntry == nil { + return // if no valid README entry found, skip rendering the README + } + ctx.Data["RawFileLink"] = "" ctx.Data["ReadmeInList"] = path.Join(subfolder, readmeFile.Name()) // the relative path to the readme file to the current tree path ctx.Data["ReadmeExist"] = true ctx.Data["FileIsSymlink"] = readmeFile.IsLink() - buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, target.Blob()) + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, readmeTargetEntry.Blob()) if err != nil { ctx.ServerError("getFileReader", err) return } defer dataRc.Close() - ctx.Data["FileIsText"] = fInfo.isTextFile - ctx.Data["FileTreePath"] = path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name()) + ctx.Data["FileIsText"] = fInfo.st.IsText() + ctx.Data["FileTreePath"] = readmeFullPath ctx.Data["FileSize"] = fInfo.fileSize - ctx.Data["IsLFSFile"] = fInfo.isLFSFile + ctx.Data["IsLFSFile"] = fInfo.isLFSFile() - if fInfo.isLFSFile { + if fInfo.isLFSFile() { filenameBase64 := base64.RawURLEncoding.EncodeToString([]byte(readmeFile.Name())) ctx.Data["RawFileLink"] = fmt.Sprintf("%s.git/info/lfs/objects/%s/%s", ctx.Repo.Repository.Link(), url.PathEscape(fInfo.lfsMeta.Oid), url.PathEscape(filenameBase64)) } - if !fInfo.isTextFile { + if !fInfo.st.IsText() { return } if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { // Pretend that this is a normal text file to display 'This file is too large to be shown' ctx.Data["IsFileTooLarge"] = true - ctx.Data["IsTextFile"] = true return } @@ -190,10 +196,10 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Join(ctx.Repo.TreePath, subfolder), + CurrentTreePath: path.Dir(readmeFullPath), }). WithMarkupType(markupType). - WithRelativePath(path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())) // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path). + WithRelativePath(readmeFullPath) ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) if err != nil { @@ -212,7 +218,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale) } - if !fInfo.isLFSFile && ctx.Repo.Repository.CanEnableEditor() { + if !fInfo.isLFSFile() && ctx.Repo.Repository.CanEnableEditor() { ctx.Data["CanEditReadmeFile"] = true } } diff --git a/routers/web/user/package.go b/routers/web/user/package.go index fd33a81901..216acdf927 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -203,9 +203,6 @@ func ViewPackageVersion(ctx *context.Context) { } ctx.Data["PackageRegistryHost"] = registryHostURL.Host - var pvs []*packages_model.PackageVersion - pvsTotal := int64(0) - switch pd.Package.Type { case packages_model.TypeAlpine: branches := make(container.Set[string]) @@ -296,12 +293,16 @@ func ViewPackageVersion(ctx *context.Context) { } } ctx.Data["ContainerImageMetadata"] = imageMetadata + } + var pvs []*packages_model.PackageVersion + var pvsTotal int64 + if pd.Package.Type == packages_model.TypeContainer { pvs, pvsTotal, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{ Paginator: db.NewAbsoluteListOptions(0, 5), PackageID: pd.Package.ID, IsTagged: true, }) - default: + } else { pvs, pvsTotal, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ Paginator: db.NewAbsoluteListOptions(0, 5), PackageID: pd.Package.ID, @@ -312,7 +313,6 @@ func ViewPackageVersion(ctx *context.Context) { ctx.ServerError("", err) return } - ctx.Data["LatestVersions"] = pvs ctx.Data["TotalVersionCount"] = pvsTotal diff --git a/services/repository/files/tree.go b/services/repository/files/tree.go index a3c3d20238..f2cbacbf1c 100644 --- a/services/repository/files/tree.go +++ b/services/repository/files/tree.go @@ -161,7 +161,7 @@ func newTreeViewNodeFromEntry(ctx context.Context, renderedIconPool *fileicon.Re FullPath: path.Join(parentDir, entry.Name()), } - entryInfo := fileicon.EntryInfoFromGitTreeEntry(entry) + entryInfo := fileicon.EntryInfoFromGitTreeEntry(commit, node.FullPath, entry) node.EntryIcon = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo) if entryInfo.EntryMode.IsDir() { entryInfo.IsOpen = true diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 9596fe837a..c4d9f0741f 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -82,6 +82,8 @@ </table> {{end}}{{/* end if .IsFileTooLarge */}} <div class="code-line-menu tippy-target"> + {{/*FIXME: the "HasSourceRenderedToggle" is never set on blame page, it should mean "whether the file is renderable". + If the file is renderable, then it must has the "display=source" parameter to make sure the file view page shows the source code, then line number works. */}} {{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}} <a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a> {{end}} diff --git a/templates/repo/branch/list.tmpl b/templates/repo/branch/list.tmpl index fffe3a08cc..9e86641c6f 100644 --- a/templates/repo/branch/list.tmpl +++ b/templates/repo/branch/list.tmpl @@ -107,8 +107,14 @@ {{end}} </td> <td class="two wide ui"> - {{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}} - <div class="commit-divergence"> + {{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}} + {{$tooltipDivergence := ""}} + {{if or .CommitsBehind .CommitsAhead}} + {{$tooltipDivergence = ctx.Locale.Tr "repo.branch.commits_divergence_from" .CommitsBehind .CommitsAhead $.DefaultBranchBranch.DBBranch.Name}} + {{else}} + {{$tooltipDivergence = ctx.Locale.Tr "repo.branch.commits_no_divergence" $.DefaultBranchBranch.DBBranch.Name}} + {{end}} + <div class="commit-divergence" data-tooltip-content="{{$tooltipDivergence}}"> <div class="bar-group"> <div class="count count-behind">{{.CommitsBehind}}</div> {{/* old code bears 0/0.0 = NaN output, so it might output invalid "width: NaNpx", it just works and doesn't caues any problem. */}} @@ -119,7 +125,7 @@ <div class="bar bar-ahead" style="width: {{Eval 100 "*" .CommitsAhead "/" "(" .CommitsBehind "+" .CommitsAhead "+" 0.0 ")"}}%"></div> </div> </div> - {{end}} + {{end}} </td> <td class="two wide tw-text-right"> {{if not .LatestPullRequest}} diff --git a/templates/repo/editor/common_breadcrumb.tmpl b/templates/repo/editor/common_breadcrumb.tmpl index df36f00504..8cfbe09d3e 100644 --- a/templates/repo/editor/common_breadcrumb.tmpl +++ b/templates/repo/editor/common_breadcrumb.tmpl @@ -5,7 +5,7 @@ {{range $i, $v := .TreeNames}} <div class="breadcrumb-divider">/</div> {{if eq $i $l}} - <input id="file-name" maxlength="255" value="{{$v}}" placeholder="{{ctx.Locale.Tr (Iif $.PageIsUpload "repo.editor.add_subdir" "repo.editor.name_your_file")}}" data-editorconfig="{{$.EditorconfigJson}}" required autofocus> + <input id="file-name" maxlength="255" value="{{$v}}" placeholder="{{ctx.Locale.Tr (Iif $.PageIsUpload "repo.editor.add_subdir" "repo.editor.name_your_file")}}" data-editorconfig="{{$.EditorconfigJson}}" {{Iif $.PageIsUpload "" "required"}} autofocus> <span data-tooltip-content="{{ctx.Locale.Tr "repo.editor.filename_help"}}">{{svg "octicon-info"}}</span> {{else}} <span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span> diff --git a/templates/repo/issue/filter_item_label.tmpl b/templates/repo/issue/filter_item_label.tmpl index 0883d93804..04c3605a6a 100644 --- a/templates/repo/issue/filter_item_label.tmpl +++ b/templates/repo/issue/filter_item_label.tmpl @@ -22,7 +22,7 @@ <span class="label-filter-exclude-info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span> <div class="divider"></div> <a class="item label-filter-query-default" href="{{QueryBuild $queryLink "labels" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a> - <a class="item label-filter-query-not-set" href="{{QueryBuild $queryLink "labels" 0}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a> + <a class="item label-filter-query-not-set" href="{{QueryBuild $queryLink "labels" "0"}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a> {{/* The logic here is not the same as the label selector in the issue sidebar. The one in the issue sidebar renders "repo labels | divider | org labels". Maybe the logic should be updated to be consistent.*/}} diff --git a/templates/repo/issue/filter_list.tmpl b/templates/repo/issue/filter_list.tmpl index 60611f1701..bfdf94513e 100644 --- a/templates/repo/issue/filter_list.tmpl +++ b/templates/repo/issue/filter_list.tmpl @@ -15,7 +15,7 @@ <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_milestone"}}"> </div> <div class="divider"></div> - <a class="{{if not $.MilestoneID}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" 0}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_all"}}</a> + <a class="{{if not $.MilestoneID}}active selected {{end}}item" href="{{QueryBuild $queryLink "milestone" NIL}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_all"}}</a> <a class="{{if $.MilestoneID}}{{if eq $.MilestoneID -1}}active selected {{end}}{{end}}item" href="{{QueryBuild $queryLink "milestone" -1}}">{{ctx.Locale.Tr "repo.issues.filter_milestone_none"}}</a> {{if .OpenMilestones}} <div class="divider"></div> diff --git a/templates/repo/issue/sidebar/wip_switch.tmpl b/templates/repo/issue/sidebar/wip_switch.tmpl index b007399deb..8c40908f62 100644 --- a/templates/repo/issue/sidebar/wip_switch.tmpl +++ b/templates/repo/issue/sidebar/wip_switch.tmpl @@ -1,5 +1,5 @@ {{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .HasMerged) (not .Issue.IsClosed) (not .IsPullWorkInProgress)}} - <a class="toggle-wip tw-block tw-mt-2" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title"> + <a data-global-init="initPullRequestWipToggle" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title"> {{ctx.Locale.Tr "repo.pulls.still_in_progress"}} {{ctx.Locale.Tr "repo.pulls.add_prefix" (index .PullRequestWorkInProgressPrefixes 0)}} </a> {{end}} diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl index 641520247d..46bcd3b8b3 100644 --- a/templates/repo/issue/view_content/pull_merge_box.tmpl +++ b/templates/repo/issue/view_content/pull_merge_box.tmpl @@ -95,7 +95,7 @@ {{ctx.Locale.Tr "repo.pulls.cannot_merge_work_in_progress"}} </div> {{if or .HasIssuesOrPullsWritePermission .IsIssuePoster}} - <button class="ui compact button toggle-wip" data-title="{{.Issue.Title}}" data-wip-prefix="{{.WorkInProgressPrefix}}" data-update-url="{{.Issue.Link}}/title"> + <button class="ui compact button" data-global-init="initPullRequestWipToggle" data-title="{{.Issue.Title}}" data-wip-prefix="{{.WorkInProgressPrefix}}" data-update-url="{{.Issue.Link}}/title"> {{ctx.Locale.Tr "repo.pulls.remove_prefix" .WorkInProgressPrefix}} </button> {{end}} diff --git a/templates/repo/settings/lfs_file.tmpl b/templates/repo/settings/lfs_file.tmpl index 1a8014e218..cd1b168401 100644 --- a/templates/repo/settings/lfs_file.tmpl +++ b/templates/repo/settings/lfs_file.tmpl @@ -30,8 +30,6 @@ <audio controls src="{{$.RawFileLink}}"> <strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong> </audio> - {{else if .IsPDFFile}} - <div class="pdf-content is-loading" data-global-init="initPdfViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "diff.view_file"}}"></div> {{else}} <a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a> {{end}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index b49818c6b7..1486d7181d 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -1,4 +1,6 @@ -<div {{if .ReadmeInList}}id="readme" {{end}}class="{{TabSizeClass .Editorconfig .FileTreePath}} non-diff-file-content"> +<div {{if .ReadmeInList}}id="readme"{{end}} class="{{TabSizeClass .Editorconfig .FileTreePath}} non-diff-file-content" + data-global-init="initRepoFileView" data-raw-file-link="{{.RawFileLink}}"> + {{- if .FileError}} <div class="ui error message"> <div class="text left tw-whitespace-pre">{{.FileError}}</div> @@ -32,13 +34,14 @@ {{template "repo/file_info" .}} {{end}} </div> - <div class="file-header-right file-actions tw-flex tw-items-center tw-flex-wrap"> - {{if .HasSourceRenderedToggle}} - <div class="ui compact icon buttons"> - <a href="?display=source" class="ui mini basic button {{if .IsDisplayingSource}}active{{end}}" data-tooltip-content="{{ctx.Locale.Tr "repo.file_view_source"}}">{{svg "octicon-code" 15}}</a> - <a href="{{$.Link}}" class="ui mini basic button {{if .IsDisplayingRendered}}active{{end}}" data-tooltip-content="{{ctx.Locale.Tr "repo.file_view_rendered"}}">{{svg "octicon-file" 15}}</a> - </div> - {{end}} + <div class="file-header-right file-actions flex-text-block tw-flex-wrap"> + {{/* this componment is also controlled by frontend plugin renders */}} + <div class="ui compact icon buttons file-view-toggle-buttons {{Iif .HasSourceRenderedToggle "" "tw-hidden"}}"> + {{if .IsRepresentableAsText}} + <a href="?display=source" class="ui mini basic button file-view-toggle-source {{if .IsDisplayingSource}}active{{end}}" data-tooltip-content="{{ctx.Locale.Tr "repo.file_view_source"}}">{{svg "octicon-code" 15}}</a> + {{end}} + <a href="?display=rendered" class="ui mini basic button file-view-toggle-rendered {{if not .IsDisplayingSource}}active{{end}}" data-tooltip-content="{{ctx.Locale.Tr "repo.file_view_rendered"}}">{{svg "octicon-file" 15}}</a> + </div> {{if not .ReadmeInList}} <div class="ui buttons tw-mr-1"> <a class="ui mini basic button" href="{{$.RawFileLink}}">{{ctx.Locale.Tr "repo.file_raw"}}</a> @@ -55,7 +58,10 @@ {{end}} </div> <a download class="btn-octicon" data-tooltip-content="{{ctx.Locale.Tr "repo.download_file"}}" href="{{$.RawFileLink}}">{{svg "octicon-download"}}</a> - <a class="btn-octicon {{if not .CanCopyContent}} disabled{{end}}" data-global-click="onCopyContentButtonClick" {{if or .IsImageFile (and .HasSourceRenderedToggle (not .IsDisplayingSource))}} data-link="{{$.RawFileLink}}"{{end}} data-tooltip-content="{{if .CanCopyContent}}{{ctx.Locale.Tr "copy_content"}}{{else}}{{ctx.Locale.Tr "copy_type_unsupported"}}{{end}}">{{svg "octicon-copy"}}</a> + <a class="btn-octicon {{if not .CanCopyContent}}disabled{{end}}" data-global-click="onCopyContentButtonClick" + {{if not .IsDisplayingSource}}data-raw-file-link="{{$.RawFileLink}}"{{end}} + data-tooltip-content="{{if .CanCopyContent}}{{ctx.Locale.Tr "copy_content"}}{{else}}{{ctx.Locale.Tr "copy_type_unsupported"}}{{end}}" + >{{svg "octicon-copy"}}</a> {{if .EnableFeed}} <a class="btn-octicon" href="{{$.RepoLink}}/rss/{{$.RefTypeNameSubURL}}/{{PathEscapeSegments .TreePath}}" data-tooltip-content="{{ctx.Locale.Tr "rss_feed"}}"> {{svg "octicon-rss"}} @@ -82,41 +88,24 @@ {{end}} </div> </h4> + <div class="ui bottom attached table unstackable segment"> - {{if not (or .IsMarkup .IsRenderedHTML)}} - {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}} + {{if not .IsMarkup}} + {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{end}} - <div class="file-view{{if .IsMarkup}} markup {{.MarkupType}}{{else if .IsPlainText}} plain-text{{else if .IsTextSource}} code-view{{end}}"> + <div class="file-view {{if .IsMarkup}}markup {{.MarkupType}}{{else if .IsPlainText}}plain-text{{else if .IsDisplayingSource}}code-view{{end}}"> {{if .IsFileTooLarge}} {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} {{else if not .FileSize}} {{template "shared/fileisempty"}} {{else if .IsMarkup}} - {{if .FileContent}}{{.FileContent}}{{end}} + {{.FileContent}} {{else if .IsPlainText}} <pre>{{if .FileContent}}{{.FileContent}}{{end}}</pre> - {{else if not .IsTextSource}} - <div class="view-raw"> - {{if .IsImageFile}} - <img alt="{{$.RawFileLink}}" src="{{$.RawFileLink}}"> - {{else if .IsVideoFile}} - <video controls src="{{$.RawFileLink}}"> - <strong>{{ctx.Locale.Tr "repo.video_not_supported_in_browser"}}</strong> - </video> - {{else if .IsAudioFile}} - <audio controls src="{{$.RawFileLink}}"> - <strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong> - </audio> - {{else if .IsPDFFile}} - <div class="pdf-content is-loading" data-global-init="initPdfViewer" data-src="{{$.RawFileLink}}" data-fallback-button-text="{{ctx.Locale.Tr "repo.diff.view_file"}}"></div> - {{else}} - <a href="{{$.RawFileLink}}" rel="nofollow" class="tw-p-4">{{ctx.Locale.Tr "repo.file_view_raw"}}</a> - {{end}} - </div> - {{else if .FileSize}} + {{else if .FileContent}} <table> <tbody> - {{range $idx, $code := .FileContent}} + {{range $idx, $code := .FileContent}} {{$line := Eval $idx "+" 1}} <tr> <td id="L{{$line}}" class="lines-num"><span id="L{{$line}}" data-line-number="{{$line}}"></span></td> @@ -125,17 +114,38 @@ {{end}} <td rel="L{{$line}}" class="lines-code chroma"><code class="code-inner">{{$code}}</code></td> </tr> - {{end}} + {{end}} </tbody> </table> - <div class="code-line-menu tippy-target"> - {{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}} - <a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a> + {{else}} + <div class="view-raw"> + {{if .IsImageFile}} + <img alt="{{$.RawFileLink}}" src="{{$.RawFileLink}}"> + {{else if .IsVideoFile}} + <video controls src="{{$.RawFileLink}}"> + <strong>{{ctx.Locale.Tr "repo.video_not_supported_in_browser"}}</strong> + </video> + {{else if .IsAudioFile}} + <audio controls src="{{$.RawFileLink}}"> + <strong>{{ctx.Locale.Tr "repo.audio_not_supported_in_browser"}}</strong> + </audio> + {{else}} + <div class="file-view-render-container"> + <div class="file-view-raw-prompt tw-p-4"> + <a href="{{$.RawFileLink}}" rel="nofollow">{{ctx.Locale.Tr "repo.file_view_raw"}}</a> + </div> + </div> {{end}} - <a class="item view_git_blame" role="menuitem" href="{{.Repository.Link}}/blame/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}">{{ctx.Locale.Tr "repo.view_git_blame"}}</a> - <a class="item copy-line-permalink" role="menuitem" data-url="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}">{{ctx.Locale.Tr "repo.file_copy_permalink"}}</a> </div> {{end}} </div> + + <div class="code-line-menu tippy-target"> + {{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}} + <a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a> + {{end}} + <a class="item view_git_blame" role="menuitem" href="{{.Repository.Link}}/blame/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}">{{ctx.Locale.Tr "repo.view_git_blame"}}</a> + <a class="item copy-line-permalink" role="menuitem" data-url="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}">{{ctx.Locale.Tr "repo.file_copy_permalink"}}</a> + </div> </div> </div> diff --git a/templates/repo/view_list.tmpl b/templates/repo/view_list.tmpl index c8ee059e89..b655f735a3 100644 --- a/templates/repo/view_list.tmpl +++ b/templates/repo/view_list.tmpl @@ -41,6 +41,9 @@ </a> {{else}} <a class="entry-name" href="{{$.TreeLink}}/{{PathEscapeSegments $entry.Name}}" title="{{$entry.Name}}">{{$entry.Name}}</a> + {{if $entry.IsLink}} + <a class="entry-symbol-link flex-text-inline" data-tooltip-content title="{{ctx.Locale.Tr "repo.find_file.follow_symlink"}}" href="{{$.TreeLink}}/{{PathEscapeSegments $entry.Name}}?follow_symlink=1">{{svg "octicon-link" 12}}</a> + {{end}} {{end}} {{end}} </div> diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index ff66bebfda..ffac8edec2 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -766,7 +766,7 @@ }, { "type": "string", - "description": "user's login name to search for", + "description": "identifier of the user, provided by the external authenticator", "name": "login_name", "in": "query" }, @@ -842,7 +842,7 @@ "parameters": [ { "type": "string", - "description": "username of user to delete", + "description": "username of the user to delete", "name": "username", "in": "path", "required": true @@ -884,7 +884,7 @@ "parameters": [ { "type": "string", - "description": "username of user to edit", + "description": "username of the user whose data is to be edited", "name": "username", "in": "path", "required": true @@ -926,7 +926,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose badges are to be listed", "name": "username", "in": "path", "required": true @@ -956,7 +956,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user to whom a badge is to be added", "name": "username", "in": "path", "required": true @@ -990,7 +990,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose badge is to be deleted", "name": "username", "in": "path", "required": true @@ -1032,7 +1032,7 @@ "parameters": [ { "type": "string", - "description": "username of the user", + "description": "username of the user who is to receive a public key", "name": "username", "in": "path", "required": true @@ -1071,7 +1071,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose public key is to be deleted", "name": "username", "in": "path", "required": true @@ -1114,7 +1114,7 @@ "parameters": [ { "type": "string", - "description": "username of the user that will own the created organization", + "description": "username of the user who will own the created organization", "name": "username", "in": "path", "required": true @@ -1154,7 +1154,7 @@ "parameters": [ { "type": "string", - "description": "existing username of user", + "description": "current username of the user", "name": "username", "in": "path", "required": true @@ -1197,7 +1197,7 @@ "parameters": [ { "type": "string", - "description": "username of the user. This user will own the created repository", + "description": "username of the user who will own the created repository", "name": "username", "in": "path", "required": true @@ -2716,7 +2716,7 @@ }, { "type": "string", - "description": "user to check", + "description": "username of the user to check", "name": "username", "in": "path", "required": true @@ -2747,7 +2747,7 @@ }, { "type": "string", - "description": "user to block", + "description": "username of the user to block", "name": "username", "in": "path", "required": true @@ -2787,7 +2787,7 @@ }, { "type": "string", - "description": "user to unblock", + "description": "username of the user to unblock", "name": "username", "in": "path", "required": true @@ -3258,7 +3258,7 @@ }, { "type": "string", - "description": "username of the user", + "description": "username of the user to check for an organization membership", "name": "username", "in": "path", "required": true @@ -3295,7 +3295,7 @@ }, { "type": "string", - "description": "username of the user", + "description": "username of the user to remove from the organization", "name": "username", "in": "path", "required": true @@ -3369,7 +3369,7 @@ }, { "type": "string", - "description": "username of the user", + "description": "username of the user to check for a public organization membership", "name": "username", "in": "path", "required": true @@ -3403,7 +3403,7 @@ }, { "type": "string", - "description": "username of the user", + "description": "username of the user whose membership is to be publicized", "name": "username", "in": "path", "required": true @@ -3440,7 +3440,7 @@ }, { "type": "string", - "description": "username of the user", + "description": "username of the user whose membership is to be concealed", "name": "username", "in": "path", "required": true @@ -6930,7 +6930,7 @@ }, { "type": "string", - "description": "username of the collaborator", + "description": "username of the user to check for being a collaborator", "name": "collaborator", "in": "path", "required": true @@ -6974,7 +6974,7 @@ }, { "type": "string", - "description": "username of the collaborator to add", + "description": "username of the user to add or update as a collaborator", "name": "collaborator", "in": "path", "required": true @@ -7074,7 +7074,7 @@ }, { "type": "string", - "description": "username of the collaborator", + "description": "username of the collaborator whose permissions are to be obtained", "name": "collaborator", "in": "path", "required": true @@ -11951,7 +11951,7 @@ }, { "type": "string", - "description": "user to subscribe", + "description": "username of the user to subscribe the issue to", "name": "user", "in": "path", "required": true @@ -12009,7 +12009,7 @@ }, { "type": "string", - "description": "user witch unsubscribe", + "description": "username of the user to unsubscribe from an issue", "name": "user", "in": "path", "required": true @@ -16821,7 +16821,7 @@ }, { "type": "string", - "description": "username of user", + "description": "username of the user whose tracked times are to be listed", "name": "user", "in": "path", "required": true @@ -17844,7 +17844,7 @@ }, { "type": "string", - "description": "username of the member to list", + "description": "username of the user whose data is to be listed", "name": "username", "in": "path", "required": true @@ -17879,7 +17879,7 @@ }, { "type": "string", - "description": "username of the user to add", + "description": "username of the user to add to a team", "name": "username", "in": "path", "required": true @@ -17917,7 +17917,7 @@ }, { "type": "string", - "description": "username of the user to remove", + "description": "username of the user to remove from a team", "name": "username", "in": "path", "required": true @@ -18896,7 +18896,7 @@ "parameters": [ { "type": "string", - "description": "user to check", + "description": "username of the user to check", "name": "username", "in": "path", "required": true @@ -18920,7 +18920,7 @@ "parameters": [ { "type": "string", - "description": "user to block", + "description": "username of the user to block", "name": "username", "in": "path", "required": true @@ -18953,7 +18953,7 @@ "parameters": [ { "type": "string", - "description": "user to unblock", + "description": "username of the user to unblock", "name": "username", "in": "path", "required": true @@ -19115,7 +19115,7 @@ "parameters": [ { "type": "string", - "description": "username of followed user", + "description": "username of the user to check for authenticated followers", "name": "username", "in": "path", "required": true @@ -19139,7 +19139,7 @@ "parameters": [ { "type": "string", - "description": "username of user to follow", + "description": "username of the user to follow", "name": "username", "in": "path", "required": true @@ -19166,7 +19166,7 @@ "parameters": [ { "type": "string", - "description": "username of user to unfollow", + "description": "username of the user to unfollow", "name": "username", "in": "path", "required": true @@ -20120,7 +20120,7 @@ "parameters": [ { "type": "string", - "description": "username of user to get", + "description": "username of the user whose data is to be listed", "name": "username", "in": "path", "required": true @@ -20149,7 +20149,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose activity feeds are to be listed", "name": "username", "in": "path", "required": true @@ -20203,7 +20203,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose followers are to be listed", "name": "username", "in": "path", "required": true @@ -20244,7 +20244,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose followed users are to be listed", "name": "username", "in": "path", "required": true @@ -20282,14 +20282,14 @@ "parameters": [ { "type": "string", - "description": "username of following user", + "description": "username of the following user", "name": "username", "in": "path", "required": true }, { "type": "string", - "description": "username of followed user", + "description": "username of the followed user", "name": "target", "in": "path", "required": true @@ -20318,7 +20318,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose GPG key list is to be obtained", "name": "username", "in": "path", "required": true @@ -20359,7 +20359,7 @@ "parameters": [ { "type": "string", - "description": "username of user to get", + "description": "username of the user whose heatmap is to be obtained", "name": "username", "in": "path", "required": true @@ -20388,7 +20388,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose public keys are to be listed", "name": "username", "in": "path", "required": true @@ -20435,7 +20435,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose organizations are to be listed", "name": "username", "in": "path", "required": true @@ -20476,7 +20476,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose permissions are to be obtained", "name": "username", "in": "path", "required": true @@ -20515,7 +20515,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose owned repos are to be listed", "name": "username", "in": "path", "required": true @@ -20556,7 +20556,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose starred repos are to be listed", "name": "username", "in": "path", "required": true @@ -20600,7 +20600,7 @@ "parameters": [ { "type": "string", - "description": "username of the user", + "description": "username of the user whose watched repos are to be listed", "name": "username", "in": "path", "required": true @@ -20641,7 +20641,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of to user whose access tokens are to be listed", "name": "username", "in": "path", "required": true @@ -20683,7 +20683,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose token is to be created", "name": "username", "in": "path", "required": true @@ -20722,7 +20722,7 @@ "parameters": [ { "type": "string", - "description": "username of user", + "description": "username of the user whose token is to be deleted", "name": "username", "in": "path", "required": true @@ -21523,7 +21523,7 @@ "x-go-name": "Time" }, "user_name": { - "description": "User who spent the time (optional)", + "description": "username of the user who spent the time working on the issue (optional)", "type": "string", "x-go-name": "User" } @@ -23058,6 +23058,7 @@ "x-go-name": "RepoAdminChangeTeamAccess" }, "username": { + "description": "username of the organization", "type": "string", "x-go-name": "UserName" }, @@ -23508,7 +23509,9 @@ "x-go-name": "FullName" }, "login_name": { + "description": "identifier of the user, provided by the external authenticator (if configured)", "type": "string", + "default": "empty", "x-go-name": "LoginName" }, "must_change_password": { @@ -23533,6 +23536,7 @@ "x-go-name": "SourceID" }, "username": { + "description": "username of the user", "type": "string", "x-go-name": "Username" }, @@ -24518,7 +24522,9 @@ "x-go-name": "Location" }, "login_name": { + "description": "identifier of the user, provided by the external authenticator (if configured)", "type": "string", + "default": "empty", "x-go-name": "LoginName" }, "max_repo_creation": { @@ -24577,6 +24583,7 @@ "x-go-name": "UserID" }, "username": { + "description": "username of the user", "type": "string", "x-go-name": "UserName" }, @@ -26301,7 +26308,7 @@ "x-go-name": "RepoAdminChangeTeamAccess" }, "username": { - "description": "deprecated", + "description": "username of the organization\ndeprecated", "type": "string", "x-go-name": "UserName" }, @@ -26545,6 +26552,7 @@ "x-go-name": "Name" }, "username": { + "description": "username of the user", "type": "string", "x-go-name": "UserName" } @@ -28008,6 +28016,7 @@ "x-go-name": "UserID" }, "user_name": { + "description": "username of the user", "type": "string", "x-go-name": "UserName" } @@ -28249,12 +28258,12 @@ "x-go-name": "Location" }, "login": { - "description": "the user's username", + "description": "login of the user, same as `username`", "type": "string", "x-go-name": "UserName" }, "login_name": { - "description": "the user's authentication sign-in name.", + "description": "identifier of the user, provided by the external authenticator (if configured)", "type": "string", "default": "empty", "x-go-name": "LoginName" diff --git a/tests/integration/api_packages_rpm_test.go b/tests/integration/api_packages_rpm_test.go index 469bd1fc6c..bd1959f64e 100644 --- a/tests/integration/api_packages_rpm_test.go +++ b/tests/integration/api_packages_rpm_test.go @@ -157,9 +157,14 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`, t.Run("Download", func(t *testing.T) { defer tests.PrintCurrentTest(t)() + // download the package without the file name req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)) resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, content, resp.Body.Bytes()) + // download the package with a file name (it can be anything) + req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s/any-file-name", groupURL, packageName, packageVersion, packageArchitecture)) + resp = MakeRequest(t, req, http.StatusOK) assert.Equal(t, content, resp.Body.Bytes()) }) @@ -447,7 +452,8 @@ gpgkey=%sapi/packages/%s/rpm/repository.key`, pub, err := openpgp.ReadArmoredKeyRing(gpgResp.Body) require.NoError(t, err) - req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)) + rpmFileName := fmt.Sprintf("%s-%s.%s.rpm", packageName, packageVersion, packageArchitecture) + req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture, rpmFileName)) resp := MakeRequest(t, req, http.StatusOK) _, sigs, err := rpmutils.Verify(resp.Body, pub) diff --git a/tests/integration/lfs_view_test.go b/tests/integration/lfs_view_test.go index 64ffebaa78..c26ece22be 100644 --- a/tests/integration/lfs_view_test.go +++ b/tests/integration/lfs_view_test.go @@ -68,14 +68,15 @@ func TestLFSRender(t *testing.T) { req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/crypt.bin") resp := session.MakeRequest(t, req, http.StatusOK) - doc := NewHTMLParser(t, resp.Body).doc + doc := NewHTMLParser(t, resp.Body) fileInfo := doc.Find("div.file-info-entry").First().Text() assert.Contains(t, fileInfo, "LFS") - rawLink, exists := doc.Find("div.file-view > div.view-raw > a").Attr("href") - assert.True(t, exists, "Download link should render instead of content because this is a binary file") - assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", rawLink, "The download link should use the proper /media link because it's in LFS") + // find new file view container + fileViewContainer := doc.Find("[data-global-init=initRepoFileView]") + assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", fileViewContainer.AttrOr("data-raw-file-link", "")) + AssertHTMLElement(t, doc, ".view-raw > .file-view-render-container > .file-view-raw-prompt", 1) }) // check that a directory with a README file shows its text diff --git a/tests/integration/repo_test.go b/tests/integration/repo_test.go index 028e8edb19..adfe07519f 100644 --- a/tests/integration/repo_test.go +++ b/tests/integration/repo_test.go @@ -27,6 +27,7 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRepoView(t *testing.T) { @@ -41,6 +42,7 @@ func TestRepoView(t *testing.T) { t.Run("BlameFileInRepo", testBlameFileInRepo) t.Run("ViewRepoDirectory", testViewRepoDirectory) t.Run("ViewRepoDirectoryReadme", testViewRepoDirectoryReadme) + t.Run("ViewRepoSymlink", testViewRepoSymlink) t.Run("MarkDownReadmeImage", testMarkDownReadmeImage) t.Run("MarkDownReadmeImageSubfolder", testMarkDownReadmeImageSubfolder) t.Run("GeneratedSourceLink", testGeneratedSourceLink) @@ -412,6 +414,21 @@ func testViewRepoDirectoryReadme(t *testing.T) { missing("symlink-loop", "/user2/readme-test/src/branch/symlink-loop/") } +func testViewRepoSymlink(t *testing.T) { + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user2/readme-test/src/branch/symlink") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + AssertHTMLElement(t, htmlDoc, ".entry-symbol-link", true) + followSymbolLinkHref := htmlDoc.Find(".entry-symbol-link").AttrOr("href", "") + require.Equal(t, "/user2/readme-test/src/branch/symlink/README.md?follow_symlink=1", followSymbolLinkHref) + + req = NewRequest(t, "GET", followSymbolLinkHref) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt?follow_symlink=1", resp.Header().Get("Location")) +} + func testMarkDownReadmeImage(t *testing.T) { defer tests.PrintCurrentTest(t)() diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css index 8edf31ddbd..deaaf83680 100644 --- a/web_src/css/modules/animations.css +++ b/web_src/css/modules/animations.css @@ -52,8 +52,7 @@ form.single-button-form.is-loading .button { } .markup pre.is-loading, -.editor-loading.is-loading, -.pdf-content.is-loading { +.editor-loading.is-loading { height: var(--height-loading); } diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 41fec58f94..a72709c382 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -183,42 +183,6 @@ td .commit-summary { cursor: default; } -.view-raw { - display: flex; - justify-content: center; - align-items: center; -} - -.view-raw > * { - max-width: 100%; -} - -.view-raw audio, -.view-raw video, -.view-raw img { - margin: 1rem 0; - border-radius: 0; - object-fit: contain; -} - -.view-raw img[src$=".svg" i] { - max-height: 600px !important; - max-width: 600px !important; -} - -.pdf-content { - width: 100%; - height: 600px; - border: none !important; - display: flex; - align-items: center; - justify-content: center; -} - -.pdf-content .pdf-fallback-button { - margin: 50px auto; -} - .repository.file.list .non-diff-file-content .plain-text { padding: 1em 2em; } @@ -241,10 +205,6 @@ td .commit-summary { padding: 0 !important; } -.non-diff-file-content .pdfobject { - border-radius: 0 0 var(--border-radius) var(--border-radius); -} - .repo-editor-header { width: 100%; } diff --git a/web_src/css/repo/file-view.css b/web_src/css/repo/file-view.css index 54af5f4602..907f136afe 100644 --- a/web_src/css/repo/file-view.css +++ b/web_src/css/repo/file-view.css @@ -60,3 +60,33 @@ .file-view.code-view .ui.button.code-line-button:hover { background: var(--color-secondary); } + +.view-raw { + display: flex; + justify-content: center; +} + +.view-raw > * { + max-width: 100%; +} + +.view-raw audio, +.view-raw video, +.view-raw img { + margin: 1rem; + border-radius: 0; + object-fit: contain; +} + +.view-raw img[src$=".svg" i] { + max-height: 600px !important; + max-width: 600px !important; +} + +.file-view-render-container { + width: 100%; +} + +.file-view-render-container :last-child { + border-radius: 0 0 var(--border-radius) var(--border-radius); /* to match the "ui segment" bottom radius */ +} diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts index 9e41673b86..96a2759a23 100644 --- a/web_src/js/bootstrap.ts +++ b/web_src/js/bootstrap.ts @@ -2,6 +2,7 @@ // to make sure the error handler always works, we should never import `window.config`, because // some user's custom template breaks it. import type {Intent} from './types.ts'; +import {html} from './utils/html.ts'; // This sets up the URL prefix used in webpack's chunk loading. // This file must be imported before any lazy-loading is being attempted. @@ -23,7 +24,7 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') { let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`); if (!msgDiv) { const el = document.createElement('div'); - el.innerHTML = `<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`; + el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`; msgDiv = el.childNodes[0] as HTMLDivElement; } // merge duplicated messages into "the message (count)" format diff --git a/web_src/js/components/ViewFileTreeStore.ts b/web_src/js/components/ViewFileTreeStore.ts index 13e2753c94..e2155bd58a 100644 --- a/web_src/js/components/ViewFileTreeStore.ts +++ b/web_src/js/components/ViewFileTreeStore.ts @@ -2,6 +2,7 @@ import {reactive} from 'vue'; import {GET} from '../modules/fetch.ts'; import {pathEscapeSegments} from '../utils/url.ts'; import {createElementFromHTML} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) { const store = reactive({ @@ -16,7 +17,7 @@ export function createViewFileTreeStore(props: { repoLink: string, treePath: str if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent); } if (poolSvgs.length) { - const svgContainer = createElementFromHTML('<div class="global-svg-icon-pool tw-hidden"></div>'); + const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`); svgContainer.innerHTML = poolSvgs.join(''); document.body.append(svgContainer); } diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index ae399e48b3..22a7890857 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -43,13 +43,16 @@ export function initGlobalDeleteButton(): void { fomanticQuery(modal).modal({ closable: false, - onApprove: async () => { + onApprove: () => { // if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."` if (btn.getAttribute('data-type') === 'form') { const formSelector = btn.getAttribute('data-form'); const form = document.querySelector<HTMLFormElement>(formSelector); if (!form) throw new Error(`no form named ${formSelector} found`); + modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal + form.classList.add('is-loading'); form.submit(); + return false; // prevent modal from closing automatically } // prepare an AJAX form by data attributes @@ -62,12 +65,15 @@ export function initGlobalDeleteButton(): void { postData.append('id', value); } } - - const response = await POST(btn.getAttribute('data-url'), {data: postData}); - if (response.ok) { - const data = await response.json(); - window.location.href = data.redirect; - } + (async () => { + const response = await POST(btn.getAttribute('data-url'), {data: postData}); + if (response.ok) { + const data = await response.json(); + window.location.href = data.redirect; + } + })(); + modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal + return false; // prevent modal from closing automatically }, }).modal('show'); }); @@ -158,13 +164,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) { } } - fomanticQuery(elModal).modal('setting', { - onApprove: () => { - // "form-fetch-action" can handle network errors gracefully, - // so keep the modal dialog to make users can re-submit the form if anything wrong happens. - if (elModal.querySelector('.form-fetch-action')) return false; - }, - }).modal('show'); + fomanticQuery(elModal).modal('show'); } export function initGlobalButtons(): void { diff --git a/web_src/js/features/comp/ConfirmModal.ts b/web_src/js/features/comp/ConfirmModal.ts index 81ea09476b..97a73eace6 100644 --- a/web_src/js/features/comp/ConfirmModal.ts +++ b/web_src/js/features/comp/ConfirmModal.ts @@ -1,5 +1,5 @@ import {svg} from '../../svg.ts'; -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../../utils/html.ts'; import {createElementFromHTML} from '../../utils/dom.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; @@ -12,17 +12,17 @@ type ConfirmModalOptions = { } export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement { - const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : ''; - return createElementFromHTML(` -<div class="ui g-modal-confirm modal"> - ${headerHtml} - <div class="content">${htmlEscape(content)}</div> - <div class="actions"> - <button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button> - <button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button> - </div> -</div> -`); + const headerHtml = header ? html`<div class="header">${header}</div>` : ''; + return createElementFromHTML(html` + <div class="ui g-modal-confirm modal"> + ${htmlRaw(headerHtml)} + <div class="content">${content}</div> + <div class="actions"> + <button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button> + <button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button> + </div> + </div> + `.trim()); } export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> { diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts index bf9ce9bfb1..bf78f58daf 100644 --- a/web_src/js/features/comp/EditorUpload.ts +++ b/web_src/js/features/comp/EditorUpload.ts @@ -114,7 +114,7 @@ async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, drop export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) { text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), ''); - text = text.replace(new RegExp(`<img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); + text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); return text; } diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts index 141c5eecfe..423440129c 100644 --- a/web_src/js/features/comp/LabelEdit.ts +++ b/web_src/js/features/comp/LabelEdit.ts @@ -72,6 +72,7 @@ export function initCompLabelEdit(pageSelector: string) { return false; } submitFormFetchAction(form); + return false; }, }).modal('show'); }; diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts index 9fedb3ed24..4b13a2141f 100644 --- a/web_src/js/features/comp/SearchUserBox.ts +++ b/web_src/js/features/comp/SearchUserBox.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {htmlEscape} from '../../utils/html.ts'; import {fomanticQuery} from '../../modules/fomantic/base.ts'; const {appSubUrl} = window.config; diff --git a/web_src/js/features/copycontent.ts b/web_src/js/features/copycontent.ts index d58f6c8246..0fec2a6235 100644 --- a/web_src/js/features/copycontent.ts +++ b/web_src/js/features/copycontent.ts @@ -9,17 +9,17 @@ const {i18n} = window.config; export function initCopyContent() { registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => { if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return; - let content; - let isRasterImage = false; - const link = btn.getAttribute('data-link'); + const rawFileLink = btn.getAttribute('data-raw-file-link'); - // when data-link is present, we perform a fetch. this is either because - // the text to copy is not in the DOM, or it is an image which should be + let content, isRasterImage = false; + + // when "data-raw-link" is present, we perform a fetch. this is either because + // the text to copy is not in the DOM, or it is an image that should be // fetched to copy in full resolution - if (link) { + if (rawFileLink) { btn.classList.add('is-loading', 'loading-icon-2px'); try { - const res = await GET(link, {credentials: 'include', redirect: 'follow'}); + const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'}); const contentType = res.headers.get('content-type'); if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) { diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts index b2ba7651c4..20f7ceb6c3 100644 --- a/web_src/js/features/dropzone.ts +++ b/web_src/js/features/dropzone.ts @@ -1,5 +1,5 @@ import {svg} from '../svg.ts'; -import {htmlEscape} from 'escape-goat'; +import {html} from '../utils/html.ts'; import {clippie} from 'clippie'; import {showTemporaryTooltip} from '../modules/tippy.ts'; import {GET, POST} from '../modules/fetch.ts'; @@ -33,14 +33,14 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi // Scale down images from HiDPI monitors. This uses the <img> tag because it's the only // method to change image size in Markdown that is supported by all implementations. // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" - fileMarkdown = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(file.name)}" src="attachments/${htmlEscape(file.uuid)}">`; + fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`; } else { // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" fileMarkdown = ``; } } else if (isVideoFile(file)) { - fileMarkdown = `<video src="attachments/${htmlEscape(file.uuid)}" title="${htmlEscape(file.name)}" controls></video>`; + fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`; } return fileMarkdown; } diff --git a/web_src/js/features/emoji.ts b/web_src/js/features/emoji.ts index 135620e51e..69afe491e2 100644 --- a/web_src/js/features/emoji.ts +++ b/web_src/js/features/emoji.ts @@ -1,4 +1,5 @@ import emojis from '../../../assets/emoji.json' with {type: 'json'}; +import {html} from '../utils/html.ts'; const {assetUrlPrefix, customEmojis} = window.config; @@ -24,12 +25,11 @@ for (const key of emojiKeys) { export function emojiHTML(name: string) { let inner; if (Object.hasOwn(customEmojis, name)) { - inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`; + inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`; } else { inner = emojiString(name); } - - return `<span class="emoji" title=":${name}:">${inner}</span>`; + return html`<span class="emoji" title=":${name}:">${inner}</span>`; } // retrieve string for given emoji name diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts new file mode 100644 index 0000000000..d803f53c0d --- /dev/null +++ b/web_src/js/features/file-view.ts @@ -0,0 +1,76 @@ +import type {FileRenderPlugin} from '../render/plugin.ts'; +import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts'; +import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts'; +import {registerGlobalInitFunc} from '../modules/observer.ts'; +import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; +import {basename} from '../utils.ts'; + +const plugins: FileRenderPlugin[] = []; + +function initPluginsOnce(): void { + if (plugins.length) return; + plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer()); +} + +function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null { + return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; +} + +function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void { + const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons'); + showElem(toggleButtons); + const displayingRendered = Boolean(renderContainer); + toggleClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist + toggleClass(toggleButtons.querySelector('.file-view-toggle-rendered'), 'active', displayingRendered); + // TODO: if there is only one button, hide it? +} + +async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) { + const elViewRawPrompt = container.querySelector('.file-view-raw-prompt'); + if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container'); + + let rendered = false, errorMsg = ''; + try { + const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + if (plugin) { + container.classList.add('is-loading'); + container.setAttribute('data-render-name', plugin.name); // not used yet + await plugin.render(container, rawFileLink); + rendered = true; + } + } catch (e) { + errorMsg = `${e}`; + } finally { + container.classList.remove('is-loading'); + } + + if (rendered) { + elViewRawPrompt.remove(); + return; + } + + // remove all children from the container, and only show the raw file link + container.replaceChildren(elViewRawPrompt); + + if (errorMsg) { + const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`); + elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage); + } +} + +export function initRepoFileView(): void { + registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => { + initPluginsOnce(); + const rawFileLink = elFileView.getAttribute('data-raw-file-link'); + const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet + // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not + const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + if (!plugin) return; + + const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container'); + showRenderRawFileButton(elFileView, renderContainer); + // maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it + if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType); + }); +} diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts index c6b5cccd54..f3ca13460c 100644 --- a/web_src/js/features/repo-editor.ts +++ b/web_src/js/features/repo-editor.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; import {createCodeEditor} from './codeeditor.ts'; import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts'; import {attachRefIssueContextPopup} from './contextpopup.ts'; @@ -87,10 +87,10 @@ export function initRepoEditor() { if (i < parts.length - 1) { if (trimValue.length) { const linkElement = createElementFromHTML( - `<span class="section"><a href="#">${htmlEscape(value)}</a></span>`, + html`<span class="section"><a href="#">${value}</a></span>`, ); const dividerElement = createElementFromHTML( - `<div class="breadcrumb-divider">/</div>`, + html`<div class="breadcrumb-divider">/</div>`, ); links.push(linkElement); dividers.push(dividerElement); @@ -113,7 +113,7 @@ export function initRepoEditor() { if (!warningDiv) { warningDiv = document.createElement('div'); warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related'); - warningDiv.innerHTML = '<p>File path contains leading or trailing whitespace.</p>'; + warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`; // Add display 'block' because display is set to 'none' in formantic\build\semantic.css warningDiv.style.display = 'block'; const inputContainer = document.querySelector('.repo-editor-header'); @@ -196,7 +196,8 @@ export function initRepoEditor() { })(); } -export function renderPreviewPanelContent(previewPanel: Element, content: string) { - previewPanel.innerHTML = `<div class="render-content markup">${content}</div>`; +export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) { + // the content is from the server, so it is safe to use innerHTML + previewPanel.innerHTML = html`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`; attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue')); } diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts index 3ea5fb70c0..762fbf51bb 100644 --- a/web_src/js/features/repo-issue-list.ts +++ b/web_src/js/features/repo-issue-list.ts @@ -1,6 +1,6 @@ import {updateIssuesMeta} from './repo-common.ts'; import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts'; -import {htmlEscape} from 'escape-goat'; +import {html} from '../utils/html.ts'; import {confirmModal} from './comp/ConfirmModal.ts'; import {showErrorToast} from '../modules/toast.ts'; import {createSortable} from '../modules/sortable.ts'; @@ -138,10 +138,10 @@ function initDropdownUserRemoteSearch(el: Element) { // the content is provided by backend IssuePosters handler processedResults.length = 0; for (const item of resp.results) { - let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`; - if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`; + let nameHtml = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${item.username}</span>`; + if (item.full_name) nameHtml += html`<span class="search-fullname tw-ml-2">${item.full_name}</span>`; if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username; - processedResults.push({value: item.username, name: html}); + processedResults.push({value: item.username, name: nameHtml}); } resp.results = processedResults; return resp; diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index bc7d4dee19..49e8fc40a2 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {html, htmlEscape} from '../utils/html.ts'; import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts'; import { addDelegatedEventListener, @@ -17,6 +17,7 @@ import {showErrorToast} from '../modules/toast.ts'; import {initRepoIssueSidebar} from './repo-issue-sidebar.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; +import {registerGlobalInitFunc} from '../modules/observer.ts'; const {appSubUrl} = window.config; @@ -45,8 +46,7 @@ export function initRepoIssueSidebarDependency() { if (String(issue.id) === currIssueId) continue; filteredResponse.results.push({ value: issue.id, - name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div> -<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`, + name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`, }); } return filteredResponse; @@ -416,25 +416,20 @@ export function initRepoIssueWipNewTitle() { export function initRepoIssueWipToggle() { // Toggle WIP for existing PR - queryElems(document, '.toggle-wip', (el) => el.addEventListener('click', async (e) => { + registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => { e.preventDefault(); - const toggleWip = el; const title = toggleWip.getAttribute('data-title'); const wipPrefix = toggleWip.getAttribute('data-wip-prefix'); const updateUrl = toggleWip.getAttribute('data-update-url'); - try { - const params = new URLSearchParams(); - params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`); - - const response = await POST(updateUrl, {data: params}); - if (!response.ok) { - throw new Error('Failed to toggle WIP status'); - } - window.location.reload(); - } catch (error) { - console.error(error); + const params = new URLSearchParams(); + params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`); + const response = await POST(updateUrl, {data: params}); + if (!response.ok) { + showErrorToast(`Failed to toggle 'work in progress' status`); + return; } + window.location.reload(); })); } diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts index 0e4d78872d..e2aa13f490 100644 --- a/web_src/js/features/repo-new.ts +++ b/web_src/js/features/repo-new.ts @@ -1,5 +1,5 @@ import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts'; -import {htmlEscape} from 'escape-goat'; +import {htmlEscape} from '../utils/html.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {sanitizeRepoName} from './repo-common.ts'; diff --git a/web_src/js/features/repo-wiki.ts b/web_src/js/features/repo-wiki.ts index f94d3ef3d1..6ae0947077 100644 --- a/web_src/js/features/repo-wiki.ts +++ b/web_src/js/features/repo-wiki.ts @@ -2,6 +2,7 @@ import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMar import {fomanticMobileScreen} from '../modules/fomantic.ts'; import {POST} from '../modules/fetch.ts'; import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; +import {html, htmlRaw} from '../utils/html.ts'; async function initRepoWikiFormEditor() { const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea'); @@ -30,7 +31,7 @@ async function initRepoWikiFormEditor() { const response = await POST(editor.previewUrl, {data: formData}); const data = await response.text(); lastContent = newContent; - previewTarget.innerHTML = `<div class="render-content markup ui segment">${data}</div>`; + previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`; } catch (error) { console.error('Error rendering preview:', error); } finally { diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts index cf98377ae7..43c21ebe6d 100644 --- a/web_src/js/features/tribute.ts +++ b/web_src/js/features/tribute.ts @@ -1,5 +1,5 @@ import {emojiKeys, emojiHTML, emojiString} from './emoji.ts'; -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; type TributeItem = Record<string, any>; @@ -26,17 +26,18 @@ export async function attachTribute(element: HTMLElement) { return emojiString(item.original); }, menuItemTemplate: (item: TributeItem) => { - return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`; + return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`; }, }, { // mentions values: window.config.mentionValues ?? [], requireLeadingSpace: true, menuItemTemplate: (item: TributeItem) => { - return ` + const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : ''; + return html` <div class="tribute-item"> - <img alt src="${htmlEscape(item.original.avatar)}" width="21" height="21"/> - <span class="name">${htmlEscape(item.original.name)}</span> - ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''} + <img alt src="${item.original.avatar}" width="21" height="21"/> + <span class="name">${item.original.name}</span> + ${htmlRaw(fullNameHtml)} </div> `; }, diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 7e84773bc1..347aad2709 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -19,7 +19,7 @@ import {initRepoIssueContentHistory} from './features/repo-issue-content.ts'; import {initStopwatch} from './features/stopwatch.ts'; import {initFindFileInRepo} from './features/repo-findfile.ts'; import {initMarkupContent} from './markup/content.ts'; -import {initPdfViewer} from './render/pdf.ts'; +import {initRepoFileView} from './features/file-view.ts'; import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; @@ -159,10 +159,11 @@ onDomReady(() => { initUserAuthWebAuthnRegister, initUserSettings, initRepoDiffView, - initPdfViewer, initColorPickers, initOAuth2SettingsDisableCheckbox, + + initRepoFileView, ]); // it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions. diff --git a/web_src/js/markup/html2markdown.ts b/web_src/js/markup/html2markdown.ts index 8c2d2f8c86..5866d0d259 100644 --- a/web_src/js/markup/html2markdown.ts +++ b/web_src/js/markup/html2markdown.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {html, htmlRaw} from '../utils/html.ts'; type Processor = (el: HTMLElement) => string | HTMLElement | void; @@ -38,10 +38,10 @@ function prepareProcessors(ctx:ProcessorContext): Processors { IMG(el: HTMLElement) { const alt = el.getAttribute('alt') || 'image'; const src = el.getAttribute('src'); - const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : ''; - const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : ''; + const widthAttr = el.hasAttribute('width') ? htmlRaw` width="${el.getAttribute('width') || ''}"` : ''; + const heightAttr = el.hasAttribute('height') ? htmlRaw` height="${el.getAttribute('height') || ''}"` : ''; if (widthAttr || heightAttr) { - return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`; + return html`<img alt="${alt}"${widthAttr}${heightAttr} src="${src}">`; } return ``; }, diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index ac24b3bcba..33d9a1ed9b 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -2,6 +2,7 @@ import {isDarkTheme} from '../utils.ts'; import {makeCodeCopyButton} from './codecopy.ts'; import {displayError} from './common.ts'; import {queryElems} from '../utils/dom.ts'; +import {html, htmlRaw} from '../utils/html.ts'; const {mermaidMaxSourceCharacters} = window.config; @@ -46,7 +47,7 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void const iframe = document.createElement('iframe'); iframe.classList.add('markup-content-iframe', 'tw-invisible'); - iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`; + iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`; const mermaidBlock = document.createElement('div'); mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); diff --git a/web_src/js/modules/fomantic/modal.ts b/web_src/js/modules/fomantic/modal.ts index b07b941590..a96c7785e1 100644 --- a/web_src/js/modules/fomantic/modal.ts +++ b/web_src/js/modules/fomantic/modal.ts @@ -9,8 +9,9 @@ const fomanticModalFn = $.fn.modal; export function initAriaModalPatch() { if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once'); $.fn.modal = ariaModalFn; - $.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden; (ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings; + $.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden; + $.fn.modal.settings.onApprove = onModalApproveDefault; } // the patched `$.fn.modal` modal function @@ -34,6 +35,29 @@ function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) { function onModalBeforeHidden(this: any) { const $modal = $(this); const elModal = $modal[0]; - queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset()); hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body); + + // reset the form after the modal is hidden, after other modal events and handlers (e.g. "onApprove", form submit) + setTimeout(() => { + queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset()); + }, 0); +} + +function onModalApproveDefault(this: any) { + const $modal = $(this); + const selectors = $modal.modal('setting', 'selector'); + const elModal = $modal[0]; + const elApprove = elModal.querySelector(selectors.approve); + const elForm = elApprove?.closest('form'); + if (!elForm) return true; // no form, just allow closing the modal + + // "form-fetch-action" can handle network errors gracefully, + // so keep the modal dialog to make users can re-submit the form if anything wrong happens. + if (elForm.matches('.form-fetch-action')) return false; + + // There is an abuse for the "modal" + "form" combination, the "Approve" button is a traditional form submit button in the form. + // Then "approve" and "submit" occur at the same time, the modal will be closed immediately before the form is submitted. + // So here we prevent the modal from closing automatically by returning false, add the "is-loading" class to the form element. + elForm.classList.add('is-loading'); + return false; } diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index f7a4b3723b..2a1d998d76 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js'; import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import {formatDatetime} from '../utils/time.ts'; import type {Content, Instance, Placement, Props} from 'tippy.js'; +import {html} from '../utils/html.ts'; type TippyOpts = { role?: string, @@ -9,7 +10,7 @@ type TippyOpts = { } & Partial<Props>; const visibleInstances = new Set<Instance>(); -const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`; +const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`; export function createTippy(target: Element, opts: TippyOpts = {}): Instance { // the callback functions should be destructured from opts, diff --git a/web_src/js/modules/toast.ts b/web_src/js/modules/toast.ts index b0afc343c3..ed807a4977 100644 --- a/web_src/js/modules/toast.ts +++ b/web_src/js/modules/toast.ts @@ -1,4 +1,4 @@ -import {htmlEscape} from 'escape-goat'; +import {htmlEscape} from '../utils/html.ts'; import {svg} from '../svg.ts'; import {animateOnce, queryElems, showElem} from '../utils/dom.ts'; import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown diff --git a/web_src/js/render/pdf.ts b/web_src/js/render/pdf.ts deleted file mode 100644 index 283b4ed85c..0000000000 --- a/web_src/js/render/pdf.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {htmlEscape} from 'escape-goat'; -import {registerGlobalInitFunc} from '../modules/observer.ts'; - -export async function initPdfViewer() { - registerGlobalInitFunc('initPdfViewer', async (el: HTMLInputElement) => { - const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); - - const src = el.getAttribute('data-src'); - const fallbackText = el.getAttribute('data-fallback-button-text'); - pdfobject.embed(src, el, { - fallbackLink: htmlEscape` - <a role="button" class="ui basic button pdf-fallback-button" href="[url]">${fallbackText}</a> - `, - }); - el.classList.remove('is-loading'); - }); -} diff --git a/web_src/js/render/plugin.ts b/web_src/js/render/plugin.ts new file mode 100644 index 0000000000..a8dd0a7c05 --- /dev/null +++ b/web_src/js/render/plugin.ts @@ -0,0 +1,10 @@ +export type FileRenderPlugin = { + // unique plugin name + name: string; + + // test if plugin can handle a specified file + canHandle: (filename: string, mimeType: string) => boolean; + + // render file content + render: (container: HTMLElement, fileUrl: string, options?: any) => Promise<void>; +} diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts new file mode 100644 index 0000000000..2a0929359d --- /dev/null +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -0,0 +1,60 @@ +import type {FileRenderPlugin} from '../plugin.ts'; +import {extname} from '../../utils.ts'; + +// support common 3D model file formats, use online-3d-viewer library for rendering + +// eslint-disable-next-line multiline-comment-style +/* a simple text STL file example: +solid SimpleTriangle + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet +endsolid SimpleTriangle +*/ + +export function newRenderPlugin3DViewer(): FileRenderPlugin { + // Some extensions are text-based formats: + // .3mf .amf .brep: XML + // .fbx: XML or BINARY + // .dae .gltf: JSON + // .ifc, .igs, .iges, .stp, .step are: TEXT + // .stl .ply: TEXT or BINARY + // .obj .off .wrl: TEXT + // So we need to be able to render when the file is recognized as plaintext file by backend. + // + // It needs more logic to make it overall right (render a text 3D model automatically): + // we need to distinguish the ambiguous filename extensions. + // For example: "*.obj, *.off, *.step" might be or not be a 3D model file. + // So when it is a text file, we can't assume that "we only render it by 3D plugin", + // otherwise the end users would be impossible to view its real content when the file is not a 3D model. + const SUPPORTED_EXTENSIONS = [ + '.3dm', '.3ds', '.3mf', '.amf', '.bim', '.brep', + '.dae', '.fbx', '.fcstd', '.glb', '.gltf', + '.ifc', '.igs', '.iges', '.stp', '.step', + '.stl', '.obj', '.off', '.ply', '.wrl', + ]; + + return { + name: '3d-model-viewer', + + canHandle(filename: string, _mimeType: string): boolean { + const ext = extname(filename).toLowerCase(); + return SUPPORTED_EXTENSIONS.includes(ext); + }, + + async render(container: HTMLElement, fileUrl: string): Promise<void> { + // TODO: height and/or max-height? + const OV = await import(/* webpackChunkName: "online-3d-viewer" */'online-3d-viewer'); + const viewer = new OV.EmbeddedViewer(container, { + backgroundColor: new OV.RGBAColor(59, 68, 76, 0), + defaultColor: new OV.RGBColor(65, 131, 196), + edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1), + }); + viewer.LoadModelFromUrlList([fileUrl]); + }, + }; +} diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts new file mode 100644 index 0000000000..40623be055 --- /dev/null +++ b/web_src/js/render/plugins/pdf-viewer.ts @@ -0,0 +1,20 @@ +import type {FileRenderPlugin} from '../plugin.ts'; + +export function newRenderPluginPdfViewer(): FileRenderPlugin { + return { + name: 'pdf-viewer', + + canHandle(filename: string, _mimeType: string): boolean { + return filename.toLowerCase().endsWith('.pdf'); + }, + + async render(container: HTMLElement, fileUrl: string): Promise<void> { + const PDFObject = await import(/* webpackChunkName: "pdfobject" */'pdfobject'); + // TODO: the PDFObject library does not support dynamic height adjustment, + container.style.height = `${window.innerHeight - 100}px`; + if (!PDFObject.default.embed(fileUrl, container)) { + throw new Error('Unable to render the PDF file'); + } + }, + }; +} diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts index 7b377e1ab4..50c9536f37 100644 --- a/web_src/js/svg.ts +++ b/web_src/js/svg.ts @@ -1,5 +1,6 @@ import {defineComponent, h, type PropType} from 'vue'; import {parseDom, serializeXml} from './utils.ts'; +import {html, htmlRaw} from './utils/html.ts'; import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg'; import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg'; import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg'; @@ -220,7 +221,7 @@ export const SvgIcon = defineComponent({ const classes = Array.from(svgOuter.classList); if (this.symbolId) { classes.push('tw-hidden', 'svg-symbol-container'); - svgInnerHtml = `<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${svgInnerHtml}</symbol>`; + svgInnerHtml = html`<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${htmlRaw(svgInnerHtml)}</symbol>`; } // create VNode return h('svg', { diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 7ed0d73406..8b540cebb1 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -314,6 +314,7 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T { htmlString = htmlString.trim(); // some tags like "tr" are special, it must use a correct parent container to create + // eslint-disable-next-line github/unescaped-html-literal -- FIXME: maybe we need to use other approaches to create elements from HTML, e.g. using DOMParser if (htmlString.startsWith('<tr')) { const container = document.createElement('table'); container.innerHTML = htmlString; diff --git a/web_src/js/utils/html.test.ts b/web_src/js/utils/html.test.ts new file mode 100644 index 0000000000..3028b7bb0a --- /dev/null +++ b/web_src/js/utils/html.test.ts @@ -0,0 +1,8 @@ +import {html, htmlEscape, htmlRaw} from './html.ts'; + +test('html', async () => { + expect(html`<a>${'<>&\'"'}</a>`).toBe(`<a><>&'"</a>`); + expect(html`<a>${htmlRaw('<img>')}</a>`).toBe(`<a><img></a>`); + expect(html`<a>${htmlRaw`<img ${'&'}>`}</a>`).toBe(`<a><img &></a>`); + expect(htmlEscape(`<a></a>`)).toBe(`<a></a>`); +}); diff --git a/web_src/js/utils/html.ts b/web_src/js/utils/html.ts new file mode 100644 index 0000000000..22e5703c34 --- /dev/null +++ b/web_src/js/utils/html.ts @@ -0,0 +1,32 @@ +export function htmlEscape(s: string, ...args: Array<any>): string { + if (args.length !== 0) throw new Error('use html or htmlRaw instead of htmlEscape'); // check legacy usages + return s.replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +class rawObject { + private readonly value: string; + constructor(v: string) { this.value = v } + toString(): string { return this.value } +} + +export function html(tmpl: TemplateStringsArray, ...parts: Array<any>): string { + let output = tmpl[0]; + for (let i = 0; i < parts.length; i++) { + const value = parts[i]; + const valueEscaped = (value instanceof rawObject) ? value.toString() : htmlEscape(String(parts[i])); + output = output + valueEscaped + tmpl[i + 1]; + } + return output; +} + +export function htmlRaw(s: string|TemplateStringsArray, ...tmplParts: Array<any>): rawObject { + if (typeof s === 'string') { + if (tmplParts.length !== 0) throw new Error("either htmlRaw('str') or htmlRaw`tmpl`"); + return new rawObject(s); + } + return new rawObject(html(s, ...tmplParts)); +} |