0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-07-23 03:24:22 +02:00
gitea/routers/web/repo/view_home.go
delvh 8dbf13b1cb
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>
2025-07-01 06:55:36 +08:00

530 lines
17 KiB
Go

// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"fmt"
"html/template"
"net/http"
"path"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
giturl "code.gitea.io/gitea/modules/git/url"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/feed"
"code.gitea.io/gitea/services/context"
repo_service "code.gitea.io/gitea/services/repository"
)
func checkOutdatedBranch(ctx *context.Context) {
if !(ctx.Repo.IsAdmin() || ctx.Repo.IsOwner()) {
return
}
// get the head commit of the branch since ctx.Repo.CommitID is not always the head commit of `ctx.Repo.BranchName`
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName)
if err != nil {
log.Error("GetBranchCommitID: %v", err)
// Don't return an error page, as it can be rechecked the next time the user opens the page.
return
}
dbBranch, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, ctx.Repo.BranchName)
if err != nil {
log.Error("GetBranch: %v", err)
// Don't return an error page, as it can be rechecked the next time the user opens the page.
return
}
if dbBranch.CommitID != commit.ID.String() {
ctx.Flash.Warning(ctx.Tr("repo.error.broken_git_hook", "https://docs.gitea.com/help/faq#push-hook--webhook--actions-arent-running"), true)
}
}
func prepareHomeSidebarRepoTopics(ctx *context.Context) {
topics, err := db.Find[repo_model.Topic](ctx, &repo_model.FindTopicOptions{
RepoID: ctx.Repo.Repository.ID,
})
if err != nil {
ctx.ServerError("models.FindTopics", err)
return
}
ctx.Data["Topics"] = topics
}
func prepareOpenWithEditorApps(ctx *context.Context) {
var tmplApps []map[string]any
apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx)
if len(apps) == 0 {
apps = setting.DefaultOpenWithEditorApps()
}
for _, app := range apps {
schema, _, _ := strings.Cut(app.OpenURL, ":")
var iconHTML template.HTML
if schema == "vscode" || schema == "vscodium" || schema == "jetbrains" {
iconHTML = svg.RenderHTML("gitea-"+schema, 16)
} else {
iconHTML = svg.RenderHTML("gitea-git", 16) // TODO: it could support user's customized icon in the future
}
tmplApps = append(tmplApps, map[string]any{
"DisplayName": app.DisplayName,
"OpenURL": app.OpenURL,
"IconHTML": iconHTML,
})
}
ctx.Data["OpenWithEditorApps"] = tmplApps
}
func prepareHomeSidebarCitationFile(entry *git.TreeEntry) func(ctx *context.Context) {
return func(ctx *context.Context) {
if entry.Name() != "" {
return
}
tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
if err != nil {
HandleGitError(ctx, "Repo.Commit.SubTree", err)
return
}
allEntries, err := tree.ListEntries()
if err != nil {
ctx.ServerError("ListEntries", err)
return
}
for _, entry := range allEntries {
if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" {
// Read Citation file contents
if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("checkCitationFile: GetBlobContent: %v", err)
} else {
ctx.Data["CitiationExist"] = true
ctx.PageData["citationFileContent"] = content
break
}
}
}
}
}
func prepareHomeSidebarLicenses(ctx *context.Context) {
repoLicenses, err := repo_model.GetRepoLicenses(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GetRepoLicenses", err)
return
}
ctx.Data["DetectedRepoLicenses"] = repoLicenses.StringList()
ctx.Data["LicenseFileName"] = repo_service.LicenseFileName
}
func prepareToRenderDirectory(ctx *context.Context) {
entries := renderDirectoryFiles(ctx, 1*time.Second)
if ctx.Written() {
return
}
if ctx.Repo.TreePath != "" {
ctx.Data["HideRepoInfo"] = true
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, ctx.Repo.TreePath, entries, true)
if err != nil {
ctx.ServerError("findReadmeFileInEntries", err)
return
}
prepareToRenderReadmeFile(ctx, subfolder, readmeFile)
}
func prepareHomeSidebarLanguageStats(ctx *context.Context) {
langs, err := repo_model.GetTopLanguageStats(ctx, ctx.Repo.Repository, 5)
if err != nil {
ctx.ServerError("Repo.GetTopLanguageStats", err)
return
}
ctx.Data["LanguageStats"] = langs
}
func prepareHomeSidebarLatestRelease(ctx *context.Context) {
if !ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeReleases) {
return
}
release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil && !repo_model.IsErrReleaseNotExist(err) {
ctx.ServerError("GetLatestReleaseByRepoID", err)
return
}
if release != nil {
if err = release.LoadAttributes(ctx); err != nil {
ctx.ServerError("release.LoadAttributes", err)
return
}
ctx.Data["LatestRelease"] = release
}
}
func prepareUpstreamDivergingInfo(ctx *context.Context) {
if !ctx.Repo.Repository.IsFork || !ctx.Repo.RefFullName.IsBranch() || ctx.Repo.TreePath != "" {
return
}
upstreamDivergingInfo, err := repo_service.GetUpstreamDivergingInfo(ctx, ctx.Repo.Repository, ctx.Repo.BranchName)
if err != nil {
if !errors.Is(err, util.ErrNotExist) && !errors.Is(err, util.ErrInvalidArgument) {
log.Error("GetUpstreamDivergingInfo: %v", err)
}
return
}
ctx.Data["UpstreamDivergingInfo"] = upstreamDivergingInfo
}
func prepareRecentlyPushedNewBranches(ctx *context.Context) {
if ctx.Doer != nil {
if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil {
ctx.ServerError("GetBaseRepo", err)
return
}
opts := &git_model.FindRecentlyPushedNewBranchesOptions{
Repo: ctx.Repo.Repository,
BaseRepo: ctx.Repo.Repository,
}
if ctx.Repo.Repository.IsFork {
opts.BaseRepo = ctx.Repo.Repository.BaseRepo
}
baseRepoPerm, err := access_model.GetUserRepoPermission(ctx, opts.BaseRepo, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
return
}
if !opts.Repo.IsMirror && !opts.BaseRepo.IsMirror &&
opts.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) &&
baseRepoPerm.CanRead(unit_model.TypePullRequests) {
var finalBranches []*git_model.RecentlyPushedNewBranch
branches, err := git_model.FindRecentlyPushedNewBranches(ctx, ctx.Doer, opts)
if err != nil {
log.Error("FindRecentlyPushedNewBranches failed: %v", err)
}
for _, branch := range branches {
divergingInfo, err := repo_service.GetBranchDivergingInfo(ctx,
branch.BranchRepo, branch.BranchName, // "base" repo for diverging info
opts.BaseRepo, opts.BaseRepo.DefaultBranch, // "head" repo for diverging info
)
if err != nil {
log.Error("GetBranchDivergingInfo failed: %v", err)
continue
}
branchRepoHasNewCommits := divergingInfo.BaseHasNewCommits
baseRepoCommitsBehind := divergingInfo.HeadCommitsBehind
if branchRepoHasNewCommits || baseRepoCommitsBehind > 0 {
finalBranches = append(finalBranches, branch)
}
}
ctx.Data["RecentlyPushedNewBranches"] = finalBranches
}
}
}
func updateContextRepoEmptyAndStatus(ctx *context.Context, empty bool, status repo_model.RepositoryStatus) {
if ctx.Repo.Repository.IsEmpty == empty && ctx.Repo.Repository.Status == status {
return
}
ctx.Repo.Repository.IsEmpty = empty
if ctx.Repo.Repository.Status == repo_model.RepositoryReady || ctx.Repo.Repository.Status == repo_model.RepositoryBroken {
ctx.Repo.Repository.Status = status // only handle ready and broken status, leave other status as-is
}
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, ctx.Repo.Repository, "is_empty", "status"); err != nil {
ctx.ServerError("updateContextRepoEmptyAndStatus: UpdateRepositoryCols", err)
return
}
}
func handleRepoEmptyOrBroken(ctx *context.Context) {
showEmpty := true
if ctx.Repo.GitRepo == nil {
// in case the repo really exists and works, but the status was incorrectly marked as "broken", we need to open and check it again
ctx.Repo.GitRepo, _ = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository)
}
if ctx.Repo.GitRepo != nil {
reallyEmpty, err := ctx.Repo.GitRepo.IsEmpty()
if err != nil {
showEmpty = true // the repo is broken
updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryBroken)
log.Error("GitRepo.IsEmpty: %v", err)
ctx.Flash.Error(ctx.Tr("error.occurred"), true)
} else if reallyEmpty {
showEmpty = true // the repo is really empty
updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryReady)
} else if branches, _, _ := ctx.Repo.GitRepo.GetBranchNames(0, 1); len(branches) == 0 {
showEmpty = true // it is not really empty, but there is no branch
// at the moment, other repo units like "actions" are not able to handle such case,
// so we just mark the repo as empty to prevent from displaying these units.
ctx.Data["RepoHasContentsWithoutBranch"] = true
updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryReady)
} else {
// the repo is actually not empty and has branches, need to update the database later
showEmpty = false
}
}
if showEmpty {
ctx.HTML(http.StatusOK, tplRepoEMPTY)
return
}
// The repo is not really empty, so we should update the model in database, such problem may be caused by:
// 1) an error occurs during pushing/receiving.
// 2) the user replaces an empty git repo manually.
updateContextRepoEmptyAndStatus(ctx, false, repo_model.RepositoryReady)
if err := repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil {
ctx.ServerError("UpdateRepoSize", err)
return
}
// the repo's IsEmpty has been updated, redirect to this page to make sure middlewares can get the correct values
link := ctx.Link
if ctx.Req.URL.RawQuery != "" {
link += "?" + ctx.Req.URL.RawQuery
}
ctx.Redirect(link)
}
func handleRepoViewSubmodule(ctx *context.Context, submodule *git.SubModule) {
submoduleRepoURL, err := giturl.ParseRepositoryURL(ctx, submodule.URL)
if err != nil {
HandleGitError(ctx, "prepareToRenderDirOrFile: ParseRepositoryURL", err)
return
}
submoduleURL := giturl.MakeRepositoryWebLink(submoduleRepoURL)
if httplib.IsCurrentGiteaSiteURL(ctx, submoduleURL) {
ctx.RedirectToCurrentSite(submoduleURL)
} else {
// don't auto-redirect to external URL, to avoid open redirect or phishing
ctx.Data["NotFoundPrompt"] = submoduleURL
ctx.NotFound(nil)
}
}
func prepareToRenderDirOrFile(entry *git.TreeEntry) func(ctx *context.Context) {
return func(ctx *context.Context) {
if entry.IsSubModule() {
submodule, err := ctx.Repo.Commit.GetSubModule(entry.Name())
if err != nil {
HandleGitError(ctx, "prepareToRenderDirOrFile: GetSubModule", err)
return
}
handleRepoViewSubmodule(ctx, submodule)
return
}
if entry.IsDir() {
prepareToRenderDirectory(ctx)
} else {
prepareFileView(ctx, entry)
}
}
}
func handleRepoHomeFeed(ctx *context.Context) bool {
if !setting.Other.EnableFeed {
return false
}
isFeed, showFeedType := feed.GetFeedType(ctx.PathParam("reponame"), ctx.Req)
if !isFeed {
return false
}
if ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType) {
feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType)
} else if ctx.Repo.TreePath == "" {
feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType)
} else {
feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType)
}
return true
}
func prepareHomeTreeSideBarSwitch(ctx *context.Context) {
showFileTree := true
if ctx.Doer != nil {
v, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyCodeViewShowFileTree, "true")
if err != nil {
log.Error("GetUserSetting: %v", err)
} else {
showFileTree, _ = strconv.ParseBool(v)
}
}
ctx.Data["UserSettingCodeViewShowFileTree"] = showFileTree
}
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.
if ctx.Repo.TreePath != "" && ctx.FormBool("raw") {
ctx.Redirect(ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath))
return true
}
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) {
return
}
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)
if ctx.Written() {
return
}
title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name
if ctx.Repo.Repository.Description != "" {
title += ": " + ctx.Repo.Repository.Description
}
ctx.Data["Title"] = title
ctx.Data["PageIsViewCode"] = true
ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show New File / Upload File buttons
if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() {
// empty or broken repositories need to be handled differently
handleRepoEmptyOrBroken(ctx)
return
}
prepareHomeTreeSideBarSwitch(ctx)
// get the current git entry which doer user is currently looking at.
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
if err != nil {
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
return
}
if redirectFollowSymlink(ctx, entry) {
return
}
// prepare the tree path
var treeNames, paths []string
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
treeLink := branchLink
if ctx.Repo.TreePath != "" {
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
treeNames = strings.Split(ctx.Repo.TreePath, "/")
for i := range treeNames {
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
}
ctx.Data["HasParentPath"] = true
if len(paths)-2 >= 0 {
ctx.Data["ParentPath"] = "/" + paths[len(paths)-2]
}
}
ctx.Data["Paths"] = paths
ctx.Data["TreeLink"] = treeLink
ctx.Data["TreeNames"] = treeNames
ctx.Data["BranchLink"] = branchLink
// some UI components are only shown when the tree path is root
isTreePathRoot := ctx.Repo.TreePath == ""
prepareFuncs := []func(*context.Context){
prepareOpenWithEditorApps,
prepareHomeSidebarRepoTopics,
checkOutdatedBranch,
prepareToRenderDirOrFile(entry),
prepareRecentlyPushedNewBranches,
}
if isTreePathRoot {
prepareFuncs = append(prepareFuncs,
prepareUpstreamDivergingInfo,
prepareHomeSidebarLicenses,
prepareHomeSidebarCitationFile(entry),
prepareHomeSidebarLanguageStats,
prepareHomeSidebarLatestRelease,
)
}
for _, prepare := range prepareFuncs {
prepare(ctx)
if ctx.Written() {
return
}
}
if ctx.FormBool("only_content") {
ctx.HTML(http.StatusOK, tplRepoViewContent)
} else if len(treeNames) != 0 {
ctx.HTML(http.StatusOK, tplRepoView)
} else {
ctx.HTML(http.StatusOK, tplRepoHome)
}
}
func RedirectRepoTreeToSrc(ctx *context.Context) {
// Redirect "/owner/repo/tree/*" requests to "/owner/repo/src/*",
// then use the deprecated "/src/*" handler to guess the ref type and render a file list page.
// This is done intentionally so that Gitea's repo URL structure matches other forges (GitHub/GitLab) provide,
// allowing us to construct submodule URLs across forges easily.
// For example, when viewing a submodule, we can simply construct the link as:
// * "https://gitea/owner/repo/tree/{CommitID}"
// * "https://github/owner/repo/tree/{CommitID}"
// * "https://gitlab/owner/repo/tree/{CommitID}"
// Then no matter which forge the submodule is using, the link works.
redirect := ctx.Repo.RepoLink + "/src/" + ctx.PathParamRaw("*")
if ctx.Req.URL.RawQuery != "" {
redirect += "?" + ctx.Req.URL.RawQuery
}
ctx.Redirect(redirect)
}
func RedirectRepoBlobToCommit(ctx *context.Context) {
// redirect "/owner/repo/blob/*" requests to "/owner/repo/src/commit/*"
// just like GitHub: browse files of a commit by "https://github/owner/repo/blob/{CommitID}"
// TODO: maybe we could guess more types to redirect to the related pages in the future
redirect := ctx.Repo.RepoLink + "/src/commit/" + ctx.PathParamRaw("*")
if ctx.Req.URL.RawQuery != "" {
redirect += "?" + ctx.Req.URL.RawQuery
}
ctx.Redirect(redirect)
}