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/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) { - if !te.IsLink() { - return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"} - } - - // read the link - r, err := te.Blob().DataAsync() - if err != nil { - return nil, err - } - closed := false - defer func() { - if !closed { - _ = r.Close() - } - }() - buf := make([]byte, te.Size()) - _, err = io.ReadFull(r, buf) - 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 t == nil { - return nil, ErrSymlinkUnresolved{te.Name(), "points outside of repo"} - } - - target, err := t.GetTreeEntryByPath(lnk) - if err != nil { - if IsErrNotExist(err) { - return nil, ErrSymlinkUnresolved{te.Name(), "broken link"} - } - return nil, err - } - return target, nil +type EntryFollowResult struct { + SymlinkContent string + TargetFullPath string + TargetEntry *TreeEntry } -// FollowLinks returns the entry ultimately pointed to by a symlink -func (te *TreeEntry) FollowLinks(optLimit ...int) (*TreeEntry, error) { +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) } + + // 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) + } + + link, err := te.Blob().GetBlobContent(maxSymlinkSize) + if err != nil { + return nil, err + } + 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) + } + + targetFullPath := path.Join(path.Dir(fullPath), link) + targetEntry, err := commit.GetTreeEntryByPath(targetFullPath) + if err != nil { + return &EntryFollowResult{SymlinkContent: link}, err + } + return &EntryFollowResult{SymlinkContent: link, TargetFullPath: targetFullPath, TargetEntry: targetEntry}, nil +} + +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() { + res, err = EntryFollowLink(commit, fullPath, treeEntry) + if err != nil { + return res, err + } + treeEntry, fullPath = res.TargetEntry, res.TargetFullPath + if !treeEntry.IsLink() { break } - next, err := entry.FollowLink() - if err != nil { - return nil, err - } - if next.ID == entry.ID { - return nil, ErrSymlinkUnresolved{entry.Name(), "recursive link"} - } - 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/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..6052177100 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2782,6 +2782,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/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 d9ff90568d..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" @@ -260,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_home.go b/routers/web/repo/view_home.go index 8ed9179290..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 @@ -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 a34de06e8e..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,22 +139,29 @@ 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 @@ -162,7 +169,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil defer dataRc.Close() ctx.Data["FileIsText"] = fInfo.st.IsText() - ctx.Data["FileTreePath"] = path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name()) + ctx.Data["FileTreePath"] = readmeFullPath ctx.Data["FileSize"] = fInfo.fileSize ctx.Data["IsLFSFile"] = fInfo.isLFSFile() @@ -189,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 { 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/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 @@ {{else}} {{$entry.Name}} + {{if $entry.IsLink}} + {{svg "octicon-link" 12}} + {{end}} {{end}} {{end}} 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)()