From c63b52c1267b87b5888f77c7c98bff83b41839db Mon Sep 17 00:00:00 2001 From: Gusted Date: Thu, 29 Feb 2024 20:51:02 +0100 Subject: [PATCH] [FEAT] Show follow symlink button - When a user goes opens a symlink file in Forgejo, the file would be rendered with the path of the symlink as content. - Add a button that is shown when the user opens a *valid* symlink file, which means that the symlink must have an valid path to an existent file and after 999 follows isn't a symlink anymore. - Return the relative path from the `FollowLink` functions, because Git really doesn't want to tell where an file is located based on the blob ID. - Adds integration tests. --- modules/base/tool.go | 2 +- modules/git/tree_entry.go | 32 +++++++++++---------- options/locale/locale_en-US.ini | 1 + routers/web/repo/view.go | 13 +++++++-- templates/repo/view_file.tmpl | 3 ++ tests/integration/repo_test.go | 51 +++++++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 18 deletions(-) diff --git a/modules/base/tool.go b/modules/base/tool.go index 168a2220b2..231507546d 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -174,7 +174,7 @@ func Int64sToStrings(ints []int64) []string { func EntryIcon(entry *git.TreeEntry) string { switch { case entry.IsLink(): - te, err := entry.FollowLink() + te, _, err := entry.FollowLink() if err != nil { log.Debug(err.Error()) return "file-symlink-file" diff --git a/modules/git/tree_entry.go b/modules/git/tree_entry.go index 9513121487..2c47c8858c 100644 --- a/modules/git/tree_entry.go +++ b/modules/git/tree_entry.go @@ -23,15 +23,15 @@ func (te *TreeEntry) Type() string { } // FollowLink returns the entry pointed to by a symlink -func (te *TreeEntry) FollowLink() (*TreeEntry, error) { +func (te *TreeEntry) FollowLink() (*TreeEntry, string, error) { if !te.IsLink() { - return nil, ErrBadLink{te.Name(), "not a symlink"} + return nil, "", ErrBadLink{te.Name(), "not a symlink"} } // read the link r, err := te.Blob().DataAsync() if err != nil { - return nil, err + return nil, "", err } closed := false defer func() { @@ -42,7 +42,7 @@ func (te *TreeEntry) FollowLink() (*TreeEntry, error) { buf := make([]byte, te.Size()) _, err = io.ReadFull(r, buf) if err != nil { - return nil, err + return nil, "", err } _ = r.Close() closed = true @@ -56,33 +56,35 @@ func (te *TreeEntry) FollowLink() (*TreeEntry, error) { } if t == nil { - return nil, ErrBadLink{te.Name(), "points outside of repo"} + return nil, "", ErrBadLink{te.Name(), "points outside of repo"} } target, err := t.GetTreeEntryByPath(lnk) if err != nil { if IsErrNotExist(err) { - return nil, ErrBadLink{te.Name(), "broken link"} + return nil, "", ErrBadLink{te.Name(), "broken link"} } - return nil, err + return nil, "", err } - return target, nil + return target, lnk, nil } // FollowLinks returns the entry ultimately pointed to by a symlink -func (te *TreeEntry) FollowLinks() (*TreeEntry, error) { +func (te *TreeEntry) FollowLinks() (*TreeEntry, string, error) { if !te.IsLink() { - return nil, ErrBadLink{te.Name(), "not a symlink"} + return nil, "", ErrBadLink{te.Name(), "not a symlink"} } entry := te + entryLink := "" for i := 0; i < 999; i++ { if entry.IsLink() { - next, err := entry.FollowLink() + next, link, err := entry.FollowLink() + entryLink = link if err != nil { - return nil, err + return nil, "", err } if next.ID == entry.ID { - return nil, ErrBadLink{ + return nil, "", ErrBadLink{ entry.Name(), "recursive link", } @@ -93,12 +95,12 @@ func (te *TreeEntry) FollowLinks() (*TreeEntry, error) { } } if entry.IsLink() { - return nil, ErrBadLink{ + return nil, "", ErrBadLink{ te.Name(), "too many levels of symbolic links", } } - return entry, nil + return entry, entryLink, nil } // returns the Tree pointed to by this TreeEntry, or nil if this is not a tree diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 0a6b1e0d82..424b063796 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1205,6 +1205,7 @@ tag = Tag released_this = released this file.title = %s at %s file_raw = Raw +file_follow = Follow Symlink file_history = History file_view_source = View Source file_view_rendered = View Rendered diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 86977062cb..32c09bb5f2 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -114,7 +114,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try log.Debug("Potential readme file: %s", entry.Name()) if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) { if entry.IsLink() { - target, err := entry.FollowLinks() + target, _, err := entry.FollowLinks() if err != nil && !git.IsErrBadLink(err) { return "", nil, err } else if target != nil && (target.IsExecutable() || target.IsRegular()) { @@ -267,7 +267,7 @@ func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, func renderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) { target := readmeFile if readmeFile != nil && readmeFile.IsLink() { - target, _ = readmeFile.FollowLinks() + target, _, _ = readmeFile.FollowLinks() } if target == nil { // if findReadmeFile() failed and/or gave us a broken symlink (which it shouldn't) @@ -391,6 +391,15 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) { ctx.Data["FileName"] = blob.Name() ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + if entry.IsLink() { + _, link, err := entry.FollowLinks() + // Errors should be allowed, because this shouldn't + // block rendering invalid symlink files. + if err == nil { + ctx.Data["SymlinkURL"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + "/" + util.PathEscapeSegments(link) + } + } + commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath) if err != nil { ctx.ServerError("GetCommitByPath", err) diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index b8eb2393fe..91b10f744a 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -43,6 +43,9 @@ {{end}} {{if not .ReadmeInList}}
+ {{if .SymlinkURL}} + {{ctx.Locale.Tr "repo.file_follow"}} + {{end}} {{ctx.Locale.Tr "repo.file_raw"}} {{if not .IsViewCommit}} {{ctx.Locale.Tr "repo.file_permalink"}} diff --git a/tests/integration/repo_test.go b/tests/integration/repo_test.go index 03124ecaf8..cb79a2fa9b 100644 --- a/tests/integration/repo_test.go +++ b/tests/integration/repo_test.go @@ -961,3 +961,54 @@ func TestRepoFilesList(t *testing.T) { assert.EqualValues(t, []string{"Charlie", "alpha", "Beta", "delta", "licensa", "LICENSE", "licensz", "README.md", "zEta"}, filesList) }) } + +func TestRepoFollowSymlink(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + + assertCase := func(t *testing.T, url, expectedSymlinkURL string, shouldExist bool) { + t.Helper() + + req := NewRequest(t, "GET", url) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + symlinkURL, ok := htmlDoc.Find(".file-actions .button[data-kind='follow-symlink']").Attr("href") + if shouldExist { + assert.True(t, ok) + assert.EqualValues(t, expectedSymlinkURL, symlinkURL) + } else { + assert.False(t, ok) + } + } + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/symlink/README.md?display=source", "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt", true) + }) + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/symlink/some/README.txt", "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt", true) + }) + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/symlink/up/back/down/down/README.md", "/user2/readme-test/src/branch/symlink/down/side/../left/right/../reelmein", true) + }) + + t.Run("Broken symlink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/fallbacks-broken-symlinks/docs/README", "", false) + }) + + t.Run("Loop symlink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/symlink-loop/README.md", "", false) + }) + + t.Run("Not a symlink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/master/README.md", "", false) + }) +}