Follow file symlinks in the UI to their target (#28835)

Symlinks are followed when you click on a link next to an entry, either
until a file has been found or until we know that the link is dead.
When the link cannot be accessed, we fall back to the current behavior
of showing the document containing the target.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
delvh 2025-07-01 00:55:36 +02:00 committed by GitHub
parent a94e472788
commit 8dbf13b1cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 240 additions and 203 deletions

View File

@ -6,17 +6,17 @@ package fileicon
import "code.gitea.io/gitea/modules/git" import "code.gitea.io/gitea/modules/git"
type EntryInfo struct { type EntryInfo struct {
FullName string BaseName string
EntryMode git.EntryMode EntryMode git.EntryMode
SymlinkToMode git.EntryMode SymlinkToMode git.EntryMode
IsOpen bool IsOpen bool
} }
func EntryInfoFromGitTreeEntry(gitEntry *git.TreeEntry) *EntryInfo { func EntryInfoFromGitTreeEntry(commit *git.Commit, fullPath string, gitEntry *git.TreeEntry) *EntryInfo {
ret := &EntryInfo{FullName: gitEntry.Name(), EntryMode: gitEntry.Mode()} ret := &EntryInfo{BaseName: gitEntry.Name(), EntryMode: gitEntry.Mode()}
if gitEntry.IsLink() { if gitEntry.IsLink() {
if te, err := gitEntry.FollowLink(); err == nil && te.IsDir() { if res, err := git.EntryFollowLink(commit, fullPath, gitEntry); err == nil && res.TargetEntry.IsDir() {
ret.SymlinkToMode = te.Mode() ret.SymlinkToMode = res.TargetEntry.Mode()
} }
} }
return ret return ret

View File

@ -5,7 +5,6 @@ package fileicon
import ( import (
"html/template" "html/template"
"path"
"strings" "strings"
"sync" "sync"
@ -134,7 +133,7 @@ func (m *MaterialIconProvider) FindIconName(entry *EntryInfo) string {
return "folder-git" return "folder-git"
} }
fileNameLower := strings.ToLower(path.Base(entry.FullName)) fileNameLower := strings.ToLower(entry.BaseName)
if entry.EntryMode.IsDir() { if entry.EntryMode.IsDir() {
if s, ok := m.rules.FolderNames[fileNameLower]; ok { if s, ok := m.rules.FolderNames[fileNameLower]; ok {
return s return s

View File

@ -20,8 +20,8 @@ func TestMain(m *testing.M) {
func TestFindIconName(t *testing.T) { func TestFindIconName(t *testing.T) {
unittest.PrepareTestEnv(t) unittest.PrepareTestEnv(t)
p := fileicon.DefaultMaterialIconProvider() 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{BaseName: "foo.php", EntryMode: git.EntryModeBlob}))
assert.Equal(t, "php", p.FindIconName(&fileicon.EntryInfo{FullName: "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{FullName: "foo.js", 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{FullName: "foo.vba", EntryMode: git.EntryModeBlob})) assert.Equal(t, "visualstudio", p.FindIconName(&fileicon.EntryInfo{BaseName: "foo.vba", EntryMode: git.EntryModeBlob}))
} }

View File

@ -20,7 +20,8 @@ import (
// Commit represents a git commit. // Commit represents a git commit.
type Commit struct { 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 ID ObjectID // The ID of this commit object
Author *Signature Author *Signature
Committer *Signature Committer *Signature

View File

@ -32,22 +32,6 @@ func (err ErrNotExist) Unwrap() error {
return util.ErrNotExist 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. // ErrBranchNotExist represents a "BranchNotExist" kind of error.
type ErrBranchNotExist struct { type ErrBranchNotExist struct {
Name string Name string

View File

@ -11,7 +11,7 @@ import (
) )
// GetTreeEntryByPath get the tree entries according the sub dir // 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 { if len(relpath) == 0 {
return &TreeEntry{ return &TreeEntry{
ptree: t, ptree: t,
@ -21,27 +21,25 @@ func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
}, nil }, nil
} }
// FIXME: This should probably use git cat-file --batch to be a bit more efficient
relpath = path.Clean(relpath) relpath = path.Clean(relpath)
parts := strings.Split(relpath, "/") parts := strings.Split(relpath, "/")
var err error
tree := t tree := t
for i, name := range parts { for _, name := range parts[:len(parts)-1] {
if i == len(parts)-1 { tree, err = tree.SubTree(name)
entries, err := tree.ListEntries() if err != nil {
if err != nil { return nil, err
return nil, err }
} }
for _, v := range entries {
if v.Name() == name { name := parts[len(parts)-1]
return v, nil entries, err := tree.ListEntries()
} if err != nil {
} return nil, err
} else { }
tree, err = tree.SubTree(name) for _, v := range entries {
if err != nil { if v.Name() == name {
return nil, err return v, nil
}
} }
} }
return nil, ErrNotExist{"", relpath} return nil, ErrNotExist{"", relpath}

View File

@ -5,7 +5,7 @@
package git package git
import ( import (
"io" "path"
"sort" "sort"
"strings" "strings"
@ -24,77 +24,57 @@ func (te *TreeEntry) Type() string {
} }
} }
// FollowLink returns the entry pointed to by a symlink type EntryFollowResult struct {
func (te *TreeEntry) FollowLink() (*TreeEntry, error) { SymlinkContent string
if !te.IsLink() { TargetFullPath string
return nil, ErrSymlinkUnresolved{te.Name(), "not a symlink"} TargetEntry *TreeEntry
}
// 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
} }
// FollowLinks returns the entry ultimately pointed to by a symlink func EntryFollowLink(commit *Commit, fullPath string, te *TreeEntry) (*EntryFollowResult, error) {
func (te *TreeEntry) FollowLinks(optLimit ...int) (*TreeEntry, error) {
if !te.IsLink() { 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) limit := util.OptionalArg(optLimit, 10)
entry := te treeEntry, fullPath := firstTreeEntry, firstFullPath
for range limit { 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 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() { if treeEntry.IsLink() {
return nil, ErrSymlinkUnresolved{te.Name(), "too many levels of symbolic links"} 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 // returns the Tree pointed to by this TreeEntry, or nil if this is not a tree

View File

@ -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)
}
}

View File

@ -19,16 +19,12 @@ type TreeEntry struct {
gogitTreeEntry *object.TreeEntry gogitTreeEntry *object.TreeEntry
ptree *Tree ptree *Tree
size int64 size int64
sized bool sized bool
fullName string
} }
// Name returns the name of the entry // Name returns the name of the entry
func (te *TreeEntry) Name() string { func (te *TreeEntry) Name() string {
if te.fullName != "" {
return te.fullName
}
return te.gogitTreeEntry.Name return te.gogitTreeEntry.Name
} }
@ -55,7 +51,7 @@ func (te *TreeEntry) Size() int64 {
return te.size return te.size
} }
// IsSubModule if the entry is a sub module // IsSubModule if the entry is a submodule
func (te *TreeEntry) IsSubModule() bool { func (te *TreeEntry) IsSubModule() bool {
return te.gogitTreeEntry.Mode == filemode.Submodule return te.gogitTreeEntry.Mode == filemode.Submodule
} }

View File

@ -15,7 +15,7 @@ type EntryMode int
// one of these. // one of these.
const ( const (
// EntryModeNoEntry is possible if the file was added or removed in a commit. In the case of // 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 EntryModeNoEntry EntryMode = 0o000000
EntryModeBlob EntryMode = 0o100644 EntryModeBlob EntryMode = 0o100644
@ -30,7 +30,7 @@ func (e EntryMode) String() string {
return strconv.FormatInt(int64(e), 8) 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 { func (e EntryMode) IsSubModule() bool {
return e == EntryModeCommit return e == EntryModeCommit
} }

View File

@ -57,7 +57,7 @@ func (te *TreeEntry) Size() int64 {
return te.size return te.size
} }
// IsSubModule if the entry is a sub module // IsSubModule if the entry is a submodule
func (te *TreeEntry) IsSubModule() bool { func (te *TreeEntry) IsSubModule() bool {
return te.entryMode.IsSubModule() return te.entryMode.IsSubModule()
} }

View File

@ -53,50 +53,3 @@ func TestEntriesCustomSort(t *testing.T) {
assert.Equal(t, "bcd", entries[6].Name()) assert.Equal(t, "bcd", entries[6].Name())
assert.Equal(t, "abc", entries[7].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")
}

View File

@ -69,7 +69,7 @@ func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) {
seen := map[plumbing.Hash]bool{} seen := map[plumbing.Hash]bool{}
walker := object.NewTreeWalker(t.gogitTree, true, seen) walker := object.NewTreeWalker(t.gogitTree, true, seen)
for { for {
fullName, entry, err := walker.Next() _, entry, err := walker.Next()
if err == io.EOF { if err == io.EOF {
break break
} }
@ -84,7 +84,6 @@ func (t *Tree) ListEntriesRecursiveWithSize() (Entries, error) {
ID: ParseGogitHash(entry.Hash), ID: ParseGogitHash(entry.Hash),
gogitTreeEntry: &entry, gogitTreeEntry: &entry,
ptree: t, ptree: t,
fullName: fullName,
} }
entries = append(entries, convertedEntry) entries = append(entries, convertedEntry)
} }

View File

@ -17,8 +17,8 @@ var (
ErrNotExist = errors.New("resource does not exist") // also implies HTTP 404 ErrNotExist = errors.New("resource does not exist") // also implies HTTP 404
ErrAlreadyExist = errors.New("resource already exists") // also implies HTTP 409 ErrAlreadyExist = errors.New("resource already exists") // also implies HTTP 409
// ErrUnprocessableContent implies HTTP 422, syntax of the request content was correct, // ErrUnprocessableContent implies HTTP 422, the syntax of the request content is correct,
// but server was unable to process the contained instructions // but the server is unable to process the contained instructions
ErrUnprocessableContent = errors.New("unprocessable content") ErrUnprocessableContent = errors.New("unprocessable content")
) )

View File

@ -2782,6 +2782,7 @@ topic.done = Done
topic.count_prompt = You cannot select more than 25 topics 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. 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.go_to_file = Go to file
find_file.no_matching = No matching file found find_file.no_matching = No matching file found

View File

@ -6,6 +6,7 @@ package repo
import ( import (
"html/template" "html/template"
"net/http" "net/http"
"path"
"strings" "strings"
pull_model "code.gitea.io/gitea/models/pull" 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 := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status}
item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed
item.NameHash = git.HashFilePathForWebUI(item.FullName) 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 { switch file.HeadMode {
case git.EntryModeTree: case git.EntryModeTree:

View File

@ -12,6 +12,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path"
"strings" "strings"
"time" "time"
@ -260,7 +261,9 @@ func prepareDirectoryFileIcons(ctx *context.Context, files []git.CommitInfo) {
renderedIconPool := fileicon.NewRenderedIconPool() renderedIconPool := fileicon.NewRenderedIconPool()
fileIcons := map[string]template.HTML{} fileIcons := map[string]template.HTML{}
for _, f := range files { 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()) fileIcons[".."] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder())
ctx.Data["FileIcons"] = fileIcons ctx.Data["FileIcons"] = fileIcons

View File

@ -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()) 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 { if err != nil {
ctx.ServerError("findReadmeFileInEntries", err) ctx.ServerError("findReadmeFileInEntries", err)
return return
@ -377,8 +377,8 @@ func prepareHomeTreeSideBarSwitch(ctx *context.Context) {
func redirectSrcToRaw(ctx *context.Context) bool { func redirectSrcToRaw(ctx *context.Context) bool {
// GitHub redirects a tree path with "?raw=1" to the raw path // GitHub redirects a tree path with "?raw=1" to the raw path
// It is useful to embed some raw contents into markdown files, // It is useful to embed some raw contents into Markdown files,
// then viewing the markdown in "src" path could embed the raw content correctly. // then viewing the Markdown in "src" path could embed the raw content correctly.
if ctx.Repo.TreePath != "" && ctx.FormBool("raw") { if ctx.Repo.TreePath != "" && ctx.FormBool("raw") {
ctx.Redirect(ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)) ctx.Redirect(ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath))
return true return true
@ -386,6 +386,20 @@ func redirectSrcToRaw(ctx *context.Context) bool {
return false 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 // Home render repository home page
func Home(ctx *context.Context) { func Home(ctx *context.Context) {
if handleRepoHomeFeed(ctx) { if handleRepoHomeFeed(ctx) {
@ -394,6 +408,7 @@ func Home(ctx *context.Context) {
if redirectSrcToRaw(ctx) { if redirectSrcToRaw(ctx) {
return return
} }
// Check whether the repo is viewable: not in migration, and the code unit should be enabled // 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. // Ideally the "feed" logic should be after this, but old code did so, so keep it as-is.
checkHomeCodeViewable(ctx) checkHomeCodeViewable(ctx)
@ -424,6 +439,10 @@ func Home(ctx *context.Context) {
return return
} }
if redirectFollowSymlink(ctx, entry) {
return
}
// prepare the tree path // prepare the tree path
var treeNames, paths []string var treeNames, paths []string
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()

View File

@ -32,15 +32,7 @@ import (
// entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries() // entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries()
// //
// FIXME: There has to be a more efficient way of doing this // 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) { func findReadmeFileInEntries(ctx *context.Context, parentDir string, 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)
docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/) docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/)
for _, entry := range entries { for _, entry := range entries {
if tryWellKnownDirs && entry.IsDir() { if tryWellKnownDirs && entry.IsDir() {
@ -62,16 +54,23 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
docsEntries[2] = entry 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 { 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 readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) {
if entry.IsLink() { if entry.IsLink() {
target, err := entry.FollowLinks() res, err := git.EntryFollowLinks(ctx.Repo.Commit, fullPath, entry)
if err != nil && !git.IsErrSymlinkUnresolved(err) { if err == nil && (res.TargetEntry.IsExecutable() || res.TargetEntry.IsRegular()) {
return "", nil, err
} else if target != nil && (target.IsExecutable() || target.IsRegular()) {
readmeFiles[i] = entry readmeFiles[i] = entry
} }
} else { } else {
@ -80,6 +79,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
} }
} }
} }
var readmeFile *git.TreeEntry var readmeFile *git.TreeEntry
for _, f := range readmeFiles { for _, f := range readmeFiles {
if f != nil { if f != nil {
@ -103,7 +103,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try
return "", nil, err return "", nil, err
} }
subfolder, readmeFile, err := findReadmeFileInEntries(ctx, childEntries, false) subfolder, readmeFile, err := findReadmeFileInEntries(ctx, parentDir, childEntries, false)
if err != nil && !git.IsErrNotExist(err) { if err != nil && !git.IsErrNotExist(err) {
return "", nil, 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) { func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) {
target := readmeFile if readmeFile == nil {
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
return 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["RawFileLink"] = ""
ctx.Data["ReadmeInList"] = path.Join(subfolder, readmeFile.Name()) // the relative path to the readme file to the current tree path 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["ReadmeExist"] = true
ctx.Data["FileIsSymlink"] = readmeFile.IsLink() 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 { if err != nil {
ctx.ServerError("getFileReader", err) ctx.ServerError("getFileReader", err)
return return
@ -162,7 +169,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
defer dataRc.Close() defer dataRc.Close()
ctx.Data["FileIsText"] = fInfo.st.IsText() 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["FileSize"] = fInfo.fileSize
ctx.Data["IsLFSFile"] = fInfo.isLFSFile() 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{ rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{
CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), CurrentRefPath: ctx.Repo.RefTypeNameSubURL(),
CurrentTreePath: path.Join(ctx.Repo.TreePath, subfolder), CurrentTreePath: path.Dir(readmeFullPath),
}). }).
WithMarkupType(markupType). 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) ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd)
if err != nil { if err != nil {

View File

@ -161,7 +161,7 @@ func newTreeViewNodeFromEntry(ctx context.Context, renderedIconPool *fileicon.Re
FullPath: path.Join(parentDir, entry.Name()), FullPath: path.Join(parentDir, entry.Name()),
} }
entryInfo := fileicon.EntryInfoFromGitTreeEntry(entry) entryInfo := fileicon.EntryInfoFromGitTreeEntry(commit, node.FullPath, entry)
node.EntryIcon = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo) node.EntryIcon = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo)
if entryInfo.EntryMode.IsDir() { if entryInfo.EntryMode.IsDir() {
entryInfo.IsOpen = true entryInfo.IsOpen = true

View File

@ -41,6 +41,9 @@
</a> </a>
{{else}} {{else}}
<a class="entry-name" href="{{$.TreeLink}}/{{PathEscapeSegments $entry.Name}}" title="{{$entry.Name}}">{{$entry.Name}}</a> <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}}
{{end}} {{end}}
</div> </div>

View File

@ -27,6 +27,7 @@ import (
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestRepoView(t *testing.T) { func TestRepoView(t *testing.T) {
@ -41,6 +42,7 @@ func TestRepoView(t *testing.T) {
t.Run("BlameFileInRepo", testBlameFileInRepo) t.Run("BlameFileInRepo", testBlameFileInRepo)
t.Run("ViewRepoDirectory", testViewRepoDirectory) t.Run("ViewRepoDirectory", testViewRepoDirectory)
t.Run("ViewRepoDirectoryReadme", testViewRepoDirectoryReadme) t.Run("ViewRepoDirectoryReadme", testViewRepoDirectoryReadme)
t.Run("ViewRepoSymlink", testViewRepoSymlink)
t.Run("MarkDownReadmeImage", testMarkDownReadmeImage) t.Run("MarkDownReadmeImage", testMarkDownReadmeImage)
t.Run("MarkDownReadmeImageSubfolder", testMarkDownReadmeImageSubfolder) t.Run("MarkDownReadmeImageSubfolder", testMarkDownReadmeImageSubfolder)
t.Run("GeneratedSourceLink", testGeneratedSourceLink) t.Run("GeneratedSourceLink", testGeneratedSourceLink)
@ -412,6 +414,21 @@ func testViewRepoDirectoryReadme(t *testing.T) {
missing("symlink-loop", "/user2/readme-test/src/branch/symlink-loop/") 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) { func testMarkDownReadmeImage(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()