From 09bb19ad01bad8588fb9190fa9397418d1228d78 Mon Sep 17 00:00:00 2001 From: Exploding Dragon Date: Mon, 30 Jun 2025 23:02:30 +0800 Subject: [PATCH 01/34] Fix: RPM package download routing & missing package version count (#34909) * Fix RPM package download routing * Fix missing package version count --------- Signed-off-by: Exploding Dragon Co-authored-by: wxiaoguang --- routers/api/packages/api.go | 2 ++ routers/web/user/package.go | 10 +++++----- tests/integration/api_packages_rpm_test.go | 8 +++++++- 3 files changed, 14 insertions(+), 6 deletions(-) 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", "//repodata/", rpm.CheckRepositoryFileExistence) g.MatchPath("GET", "//repodata/", rpm.GetRepositoryFile) g.MatchPath("PUT", "//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", "//package///", rpm.DownloadPackageFile) + g.MatchPath("HEAD,GET", "//package////", rpm.DownloadPackageFile) g.MatchPath("DELETE", "//package///", reqPackageAccess(perm.AccessModeWrite), rpm.DeletePackageFile) }, reqPackageAccess(perm.AccessModeRead)) 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/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) From a94e472788be0e956b4a231b7067af354a4213e3 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 1 Jul 2025 00:33:53 +0800 Subject: [PATCH 02/34] Fix issue filter (#34914) `0` is zero value and won't be put into query parameter by QueryBuild Fix #34913 --- templates/repo/issue/filter_item_label.tmpl | 2 +- templates/repo/issue/filter_list.tmpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 @@ {{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}
{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}} - {{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}} + {{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}} {{/* 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 @@
- {{ctx.Locale.Tr "repo.issues.filter_milestone_all"}} + {{ctx.Locale.Tr "repo.issues.filter_milestone_all"}} {{ctx.Locale.Tr "repo.issues.filter_milestone_none"}} {{if .OpenMilestones}}
From 8dbf13b1cb1235a34322fe0e2e92fa92642dd9c5 Mon Sep 17 00:00:00 2001 From: delvh Date: Tue, 1 Jul 2025 00:55:36 +0200 Subject: [PATCH 03/34] 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 --- modules/fileicon/entry.go | 10 +-- modules/fileicon/material.go | 3 +- modules/fileicon/material_test.go | 8 +- modules/git/commit.go | 3 +- modules/git/error.go | 16 ---- modules/git/tree_blob_nogogit.go | 36 +++++---- modules/git/tree_entry.go | 104 +++++++++++--------------- modules/git/tree_entry_common_test.go | 76 +++++++++++++++++++ modules/git/tree_entry_gogit.go | 10 +-- modules/git/tree_entry_mode.go | 4 +- modules/git/tree_entry_nogogit.go | 2 +- modules/git/tree_entry_test.go | 47 ------------ modules/git/tree_gogit.go | 3 +- modules/util/error.go | 4 +- options/locale/locale_en-US.ini | 1 + routers/web/repo/treelist.go | 3 +- routers/web/repo/view.go | 5 +- routers/web/repo/view_home.go | 25 ++++++- routers/web/repo/view_readme.go | 61 ++++++++------- services/repository/files/tree.go | 2 +- templates/repo/view_list.tmpl | 3 + tests/integration/repo_test.go | 17 +++++ 22 files changed, 240 insertions(+), 203 deletions(-) create mode 100644 modules/git/tree_entry_common_test.go 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)() From f3364ec57f58eee0fb3a894eb43858b5f26e043f Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Tue, 1 Jul 2025 00:43:26 +0000 Subject: [PATCH 04/34] [skip ci] Updated translations via Crowdin --- options/locale/locale_ga-IE.ini | 1 + options/locale/locale_zh-CN.ini | 66 ++++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 14 deletions(-) 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=一封新的确认邮件已经发送到 %s。请在下一个 %s 中检查您的收件箱以完成注册流程。 如果您的注册邮箱地址不正确,您可以重新登录并更改它。 must_change_password=更新您的密码 @@ -485,7 +486,7 @@ sspi_auth_failed=SSPI 认证失败 password_pwned=此密码出现在 被盗密码 列表上并且曾经被公开。 请使用另一个密码再试一次。 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=@%s 提到了您: +issue.x_mentioned_you=@%s 提及了您: issue.action.force_push=%[1]s 强制从 %[3]s 推送 %[2]s 至 [4]s。 issue.action.push_1=@%[1]s 推送了 %[3]d 个提交到 %[2]s issue.action.push_n=@%[1]s 推送了 %[3]d 个提交到 %[2]s @@ -838,7 +839,7 @@ ssh_desc=这些 SSH 公钥已经关联到您的账号。相应的私钥拥有完 principal_desc=这些 SSH 证书规则已关联到您的账号将允许完全访问您所有仓库。 gpg_desc=这些 GPG 公钥已经关联到您的账号。请妥善保管您的私钥因为他们将被用于认证提交。 ssh_helper=需要帮助? 请查看有关 如何生成 SSH 密钥常见 SSH 问题 寻找答案。 -gpg_helper=需要帮助吗?看一看 GitHub 关于 GPG 的指导。 +gpg_helper=需要帮助?看一看 GitHub 关于 GPG 的指导。 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=许可证说明了其他人可以和不可以用您的代码做什么。不确定哪一个适合您的项目?见 选择一个许可证 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=文件内容在您进行编辑时已经发生变动。单击此处 查看变动的具体内容,或者 再次提交 覆盖已发生的变动。 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=您不能直接编辑此仓库。 更改将写入您的派生仓库 %s,以便您可以创建一个拉取请求。 +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=`您已经开始对 另一个工单 进行时间跟踪!` issues.stop_tracking=停止计时器 issues.stop_tracking_history=工作 %[1]s 于 %[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 钩子似乎已损坏。 请按照 无法 被回滚。 +settings.rename_notices_2=在被人使用前,旧名称将会被重定向。 settings.update_avatar_success=组织头像已经更新。 settings.delete=删除组织 settings.delete_account=删除当前组织 -settings.delete_prompt=删除操作会永久清除该组织的信息,并且 不可恢复! +settings.delete_prompt=删除操作会永久清除该组织的信息,并且 无法 恢复! +settings.name_confirm=输入组织名称以确认: +settings.delete_notices_1=此操作 无法 被回滚。 +settings.delete_notices_2=此操作将永久删除 %s 的所有仓库,包括 Git 数据、 工单、评论、百科和协作者的操作权限。 +settings.delete_notices_3=此操作将永久删除 %s 的所有 软件包。 +settings.delete_notices_4=此操作将永久删除 %s 的所有 项目。 settings.confirm_delete_account=确认删除组织 +settings.delete_failed=由于内部错误,删除组织失败 +settings.delete_successful=组织 %s 已成功删除。 settings.hooks_desc=在此处添加的 Web 钩子将会应用到该组织下的 所有仓库。 settings.labels_desc=添加能够被该组织下的 所有仓库 的工单使用的标签。 @@ -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=查看工作流文件 From 6596b921409f0763f08e2cc2c1e466929a80be28 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 1 Jul 2025 15:19:03 +0800 Subject: [PATCH 05/34] Fix modal + form abuse (#34921) See the comment. And due to the abuse, there is a regression: when the modal is hidden, the form will be reset and it can't submit. This PR fixes all problems: keep the modal with form open, and add "loading" indicator. --- web_src/js/features/common-button.ts | 28 +++++++++++++-------------- web_src/js/features/comp/LabelEdit.ts | 1 + web_src/js/modules/fomantic/modal.ts | 28 +++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 16 deletions(-) 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(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/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/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) { 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; } From 90f96c301e9e139e351b6d68995d94e6ca679133 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 1 Jul 2025 16:32:39 +0800 Subject: [PATCH 06/34] Fix PR toggle WIP (#34920) Fix #34919 --------- Signed-off-by: wxiaoguang --- routers/web/repo/issue_view.go | 4 ++++ templates/repo/issue/sidebar/wip_switch.tmpl | 2 +- .../issue/view_content/pull_merge_box.tmpl | 2 +- web_src/js/features/repo-issue.ts | 22 ++++++++----------- 4 files changed, 15 insertions(+), 15 deletions(-) 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/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)}} - + {{ctx.Locale.Tr "repo.pulls.still_in_progress"}} {{ctx.Locale.Tr "repo.pulls.add_prefix" (index .PullRequestWorkInProgressPrefixes 0)}} {{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"}} {{if or .HasIssuesOrPullsWritePermission .IsIssuePoster}} - {{end}} diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index bc7d4dee19..c7799ec415 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -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; @@ -416,25 +417,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(); })); } From 35f0b5a3ec5681cd04da2861c37364ec2595b82f Mon Sep 17 00:00:00 2001 From: Aaron Meese Date: Tue, 1 Jul 2025 07:14:32 -0400 Subject: [PATCH 07/34] Adds tooltip on branch commit counts (#34869) Adds a tooltip to the commit counts when comparing branches, making it easier for novice users to understand what the numbers mean. Fixes #34867. --------- Signed-off-by: wxiaoguang Co-authored-by: wxiaoguang --- options/locale/locale_en-US.ini | 2 ++ templates/repo/branch/list.tmpl | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 6052177100..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 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}} - {{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}} -
+ {{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}} +
{{.CommitsBehind}}
{{/* 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 @@
- {{end}} + {{end}} {{if not .LatestPullRequest}} From 1d4ad5aa2b3a321a8d759bb91fc78e0aa6a89ed9 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 1 Jul 2025 21:44:05 +0800 Subject: [PATCH 08/34] Improve html escape (#34911) drop "escape-goat" --- .eslintrc.cjs | 3 +- package-lock.json | 13 --------- package.json | 1 - web_src/js/bootstrap.ts | 3 +- web_src/js/components/ViewFileTreeStore.ts | 3 +- web_src/js/features/comp/ConfirmModal.ts | 24 ++++++++-------- web_src/js/features/comp/EditorUpload.ts | 2 +- web_src/js/features/comp/SearchUserBox.ts | 2 +- web_src/js/features/dropzone.ts | 6 ++-- web_src/js/features/emoji.ts | 6 ++-- web_src/js/features/file-view.ts | 4 +-- web_src/js/features/repo-editor.ts | 13 +++++---- web_src/js/features/repo-issue-list.ts | 8 +++--- web_src/js/features/repo-issue.ts | 5 ++-- web_src/js/features/repo-new.ts | 2 +- web_src/js/features/repo-wiki.ts | 3 +- web_src/js/features/tribute.ts | 13 +++++---- web_src/js/markup/html2markdown.ts | 8 +++--- web_src/js/markup/mermaid.ts | 3 +- web_src/js/modules/tippy.ts | 3 +- web_src/js/modules/toast.ts | 2 +- web_src/js/svg.ts | 3 +- web_src/js/utils/dom.ts | 1 + web_src/js/utils/html.test.ts | 8 ++++++ web_src/js/utils/html.ts | 32 ++++++++++++++++++++++ 25 files changed, 103 insertions(+), 68 deletions(-) create mode 100644 web_src/js/utils/html.test.ts create mode 100644 web_src/js/utils/html.ts 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/package-lock.json b/package-lock.json index 132efb8635..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", @@ -6563,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", diff --git a/package.json b/package.json index c8a48bb5d9..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", 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(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`); if (!msgDiv) { const el = document.createElement('div'); - el.innerHTML = `
`; + el.innerHTML = html`
`; 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('
'); + const svgContainer = createElementFromHTML(html`
`); svgContainer.innerHTML = poolSvgs.join(''); document.body.append(svgContainer); } 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 ? `
${htmlEscape(header)}
` : ''; - return createElementFromHTML(` - -`); + const headerHtml = header ? html`
${header}
` : ''; + return createElementFromHTML(html` + + `.trim()); } export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise { 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(`]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); + text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); return text; } 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/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 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 = `${htmlEscape(file.name)}`; + fileMarkdown = html`${file.name}`; } 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 = `![${file.name}](/attachments/${file.uuid})`; } } else if (isVideoFile(file)) { - fileMarkdown = ``; + fileMarkdown = html``; } 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 = `:${name}:`; + inner = html`:${name}:`; } else { inner = emojiString(name); } - - return `${inner}`; + return html`${inner}`; } // retrieve string for given emoji name diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 867f946297..d803f53c0d 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -3,7 +3,7 @@ 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 {htmlEscape} from 'escape-goat'; +import {html} from '../utils/html.ts'; import {basename} from '../utils.ts'; const plugins: FileRenderPlugin[] = []; @@ -54,7 +54,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str container.replaceChildren(elViewRawPrompt); if (errorMsg) { - const elErrorMessage = createElementFromHTML(htmlEscape`
${errorMsg}
`); + const elErrorMessage = createElementFromHTML(html`
${errorMsg}
`); elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage); } } 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( - `${htmlEscape(value)}`, + html`${value}`, ); const dividerElement = createElementFromHTML( - ``, + html``, ); 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 = '

File path contains leading or trailing whitespace.

'; + warningDiv.innerHTML = html`

File path contains leading or trailing whitespace.

`; // 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 = `
${content}
`; +export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) { + // the content is from the server, so it is safe to use innerHTML + previewPanel.innerHTML = html`
${htmlRaw(htmlContent)}
`; 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 = `${htmlEscape(item.username)}`; - if (item.full_name) html += `${htmlEscape(item.full_name)}`; + let nameHtml = html`${item.username}`; + if (item.full_name) nameHtml += html`${item.full_name}`; 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 c7799ec415..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, @@ -46,8 +46,7 @@ export function initRepoIssueSidebarDependency() { if (String(issue.id) === currIssueId) continue; filteredResponse.results.push({ value: issue.id, - name: `
#${issue.number} ${htmlEscape(issue.title)}
-
${htmlEscape(issue.repository.full_name)}
`, + name: html`
#${issue.number} ${issue.title}
${issue.repository.full_name}
`, }); } return filteredResponse; 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('.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 = `
${data}
`; + previewTarget.innerHTML = html`
${htmlRaw(data)}
`; } 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; @@ -26,17 +26,18 @@ export async function attachTribute(element: HTMLElement) { return emojiString(item.original); }, menuItemTemplate: (item: TributeItem) => { - return `
${emojiHTML(item.original)}${htmlEscape(item.original)}
`; + return html`
${htmlRaw(emojiHTML(item.original))}${item.original}
`; }, }, { // mentions values: window.config.mentionValues ?? [], requireLeadingSpace: true, menuItemTemplate: (item: TributeItem) => { - return ` + const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`${item.original.fullname}` : ''; + return html`
- - ${htmlEscape(item.original.name)} - ${item.original.fullname && item.original.fullname !== '' ? `${htmlEscape(item.original.fullname)}` : ''} + + ${item.original.name} + ${htmlRaw(fullNameHtml)}
`; }, 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 `${htmlEscape(alt)}`; + return html`${alt}`; } return `![${alt}](${src})`; }, 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${svg}`; + iframe.srcdoc = html`${htmlRaw(svg)}`; const mermaidBlock = document.createElement('div'); mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden'); 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; const visibleInstances = new Set(); -const arrowSvg = ``; +const arrowSvg = html``; 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/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 = `${svgInnerHtml}`; + svgInnerHtml = html`${htmlRaw(svgInnerHtml)}`; } // 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(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(' { + expect(html`${'<>&\'"'}`).toBe(`<>&'"`); + expect(html`${htmlRaw('')}`).toBe(``); + expect(html`${htmlRaw``}`).toBe(``); + expect(htmlEscape(``)).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): 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, '>'); +} + +class rawObject { + private readonly value: string; + constructor(v: string) { this.value = v } + toString(): string { return this.value } +} + +export function html(tmpl: TemplateStringsArray, ...parts: Array): 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): 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)); +} From dd1fd89185103958cb504e362df91fd2f7856939 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Wed, 2 Jul 2025 00:37:55 +0000 Subject: [PATCH 09/34] [skip ci] Updated translations via Crowdin --- options/locale/locale_ga-IE.ini | 1 + options/locale/locale_pt-PT.ini | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini index e8c90d059b..d49da2b853 100644 --- a/options/locale/locale_ga-IE.ini +++ b/options/locale/locale_ga-IE.ini @@ -2782,6 +2782,7 @@ topic.done=Déanta topic.count_prompt=Ní féidir leat níos mó ná 25 topaicí a roghnú topic.format_prompt=Ní mór do thopaicí tosú le litir nó uimhir, is féidir daiseanna ('-') agus poncanna ('.') a áireamh, a bheith suas le 35 carachtar ar fad. Ní mór litreacha a bheith i litreacha beaga. +find_file.follow_symlink=Lean an nasc siombalach seo go dtí an áit a bhfuil sé ag pointeáil air find_file.go_to_file=Téigh go dtí an comhad find_file.no_matching=Níl aon chomhad meaitseála le fáil diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 7dcdaac4c9..2b4d644ed9 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -1562,8 +1562,8 @@ issues.filter_project=Planeamento issues.filter_project_all=Todos os planeamentos issues.filter_project_none=Nenhum planeamento issues.filter_assignee=Encarregado -issues.filter_assignee_no_assignee=Não atribuído -issues.filter_assignee_any_assignee=Atribuído a qualquer pessoa +issues.filter_assignee_no_assignee=Não atribuída +issues.filter_assignee_any_assignee=Atribuída a alguém issues.filter_poster=Autor(a) issues.filter_user_placeholder=Procurar utilizadores issues.filter_user_no_select=Todos os utilizadores @@ -1969,6 +1969,7 @@ pulls.cmd_instruction_checkout_title=Checkout pulls.cmd_instruction_checkout_desc=A partir do seu repositório, crie um novo ramo e teste nele as modificações. pulls.cmd_instruction_merge_title=Integrar pulls.cmd_instruction_merge_desc=Integrar as modificações e enviar para o Gitea. +pulls.cmd_instruction_merge_warning=Aviso: Esta operação não pode executar pedidos de integração porque a opção "auto-identificar integração manual" não está habilitada. pulls.clear_merge_message=Apagar mensagem de integração pulls.clear_merge_message_hint=Apagar a mensagem de integração apenas remove o conteúdo da mensagem de cometimento e mantém os rodapés do git, tais como "Co-Autorado-Por …". @@ -2781,6 +2782,7 @@ topic.done=Concluído topic.count_prompt=Não pode escolher mais do que 25 tópicos topic.format_prompt=Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') ou pontos ('.') e podem ter até 35 caracteres. As letras têm que ser minúsculas. +find_file.follow_symlink=Seguir esta ligação simbólica para onde ela está apontando find_file.go_to_file=Ir para o ficheiro find_file.no_matching=Não foi encontrado qualquer ficheiro correspondente From 6fe5c4c4d983a79e24c42af61cff0c0999c7438f Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 3 Jul 2025 00:00:16 +0200 Subject: [PATCH 10/34] Exclude devtest.ts from tailwindcss (#34935) Fix this leftover from the typescript migration. --- tailwind.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailwind.config.js b/tailwind.config.js index 01740d816b..fa233a9814 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -29,7 +29,7 @@ export default { important: true, // the frameworks are mixed together, so tailwind needs to override other framework's styles content: [ isProduction && '!./templates/devtest/**/*', - isProduction && '!./web_src/js/standalone/devtest.js', + isProduction && '!./web_src/js/standalone/devtest.ts', '!./templates/swagger/v1_json.tmpl', '!./templates/user/auth/oidc_wellknown.tmpl', '!**/*_test.go', From 97fc87af89a03470999a1a68e293417e36bd9b52 Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Thu, 3 Jul 2025 00:37:58 +0000 Subject: [PATCH 11/34] [skip ci] Updated translations via Crowdin --- options/locale/locale_fr-FR.ini | 3 +++ options/locale/locale_ga-IE.ini | 2 ++ options/locale/locale_pt-PT.ini | 2 ++ 3 files changed, 7 insertions(+) diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini index f09e9071fc..03614832e9 100644 --- a/options/locale/locale_fr-FR.ini +++ b/options/locale/locale_fr-FR.ini @@ -1969,6 +1969,7 @@ pulls.cmd_instruction_checkout_title=Basculer pulls.cmd_instruction_checkout_desc=Depuis votre dépôt, basculer sur une nouvelle branche et tester des modifications. pulls.cmd_instruction_merge_title=Fusionner pulls.cmd_instruction_merge_desc=Fusionner les modifications et mettre à jour sur Gitea. +pulls.cmd_instruction_merge_warning=Attention : cette opération ne peut pas fusionner la demande d’ajout car la « détection automatique de fusion manuelle » n’a pas été activée pulls.clear_merge_message=Effacer le message de fusion pulls.clear_merge_message_hint=Effacer le message de fusion ne supprimera que le message de la révision, mais pas les pieds de révision générés tels que "Co-Authored-By:". @@ -2768,6 +2769,8 @@ branch.new_branch_from=`Créer une nouvelle branche à partir de "%s"` branch.renamed=La branche %s à été renommée en %s. branch.rename_default_or_protected_branch_error=Seuls les administrateurs peuvent renommer les branches par défaut ou protégées. branch.rename_protected_branch_failed=Cette branche est protégée par des règles de protection basées sur des globs. +branch.commits_divergence_from=Divergence de révisions : %[1]d en retard et %[2]d en avance sur %[3]s +branch.commits_no_divergence=Identique à la branche %[1]s tag.create_tag=Créer l'étiquette %s tag.create_tag_operation=Créer une étiquette diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini index d49da2b853..7c7cb58dcf 100644 --- a/options/locale/locale_ga-IE.ini +++ b/options/locale/locale_ga-IE.ini @@ -2769,6 +2769,8 @@ branch.new_branch_from=`Cruthaigh brainse nua ó "%s"` branch.renamed=Ainmníodh brainse %s go %s. branch.rename_default_or_protected_branch_error=Ní féidir ach le riarthóirí brainsí réamhshocraithe nó cosanta a athainmniú. branch.rename_protected_branch_failed=Tá an brainse seo faoi chosaint ag rialacha cosanta domhanda. +branch.commits_divergence_from=Déanann sé dialltacht a thiomnú: %[1]d taobh thiar agus %[2]d chun tosaigh ar %[3]s +branch.commits_no_divergence=Mar an gcéanna le brainse %[1]s tag.create_tag=Cruthaigh clib %s tag.create_tag_operation=Cruthaigh clib diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 2b4d644ed9..740de78809 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -2769,6 +2769,8 @@ branch.new_branch_from=`Criar um novo ramo a partir do ramo "%s"` branch.renamed=O ramo %s foi renomeado para %s. branch.rename_default_or_protected_branch_error=Só os administradores é que podem renomear o ramo principal ou ramos protegidos. branch.rename_protected_branch_failed=Este ramo está protegido por regras de salvaguarda baseadas em padrões glob. +branch.commits_divergence_from=Divergência nos cometimentos: %[1]d atrás e %[2]d à frente de %[3]s +branch.commits_no_divergence=Idêntico ao ramo %[1]s tag.create_tag=Criar etiqueta %s tag.create_tag_operation=Criar etiqueta From 6455c8202beacf215ba96793e75b29859f904ae3 Mon Sep 17 00:00:00 2001 From: RickyMa Date: Thu, 3 Jul 2025 09:45:42 +0800 Subject: [PATCH 12/34] Support getting last commit message using contents-ext API (#34904) Fix #34870 Fix #34929 --------- Co-authored-by: wxiaoguang --- modules/structs/repo_file.go | 19 +++-- routers/api/v1/repo/file.go | 19 ++++- services/repository/files/content.go | 57 ++++++++------ services/repository/files/content_test.go | 74 +------------------ services/repository/files/file.go | 7 +- templates/swagger/v1_json.tmpl | 8 +- .../integration/api_repo_file_create_test.go | 11 +-- .../integration/api_repo_file_update_test.go | 7 +- .../api_repo_get_contents_list_test.go | 26 +++---- .../integration/api_repo_get_contents_test.go | 45 ++++++++--- tests/integration/repofiles_change_test.go | 18 ++--- 11 files changed, 141 insertions(+), 150 deletions(-) diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index 91ee060d50..5a86db868b 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -116,14 +116,17 @@ type ContentsExtResponse struct { // ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content type ContentsResponse struct { - Name string `json:"name"` - Path string `json:"path"` - SHA string `json:"sha"` - LastCommitSHA string `json:"last_commit_sha"` + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + + LastCommitSHA *string `json:"last_commit_sha,omitempty"` // swagger:strfmt date-time - LastCommitterDate time.Time `json:"last_committer_date"` + LastCommitterDate *time.Time `json:"last_committer_date,omitempty"` // swagger:strfmt date-time - LastAuthorDate time.Time `json:"last_author_date"` + LastAuthorDate *time.Time `json:"last_author_date,omitempty"` + LastCommitMessage *string `json:"last_commit_message,omitempty"` + // `type` will be `file`, `dir`, `symlink`, or `submodule` Type string `json:"type"` Size int64 `json:"size"` @@ -141,8 +144,8 @@ type ContentsResponse struct { SubmoduleGitURL *string `json:"submodule_git_url"` Links *FileLinksResponse `json:"_links"` - LfsOid *string `json:"lfs_oid"` - LfsSize *int64 `json:"lfs_size"` + LfsOid *string `json:"lfs_oid,omitempty"` + LfsSize *int64 `json:"lfs_size,omitempty"` } // FileCommitResponse contains information generated from a Git commit for a repo's file. diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 8ce52c2cd4..69b5996222 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -812,7 +812,8 @@ func GetContentsExt(ctx *context.APIContext) { // required: true // - name: filepath // in: path - // description: path of the dir, file, symlink or submodule in the repo + // description: path of the dir, file, symlink or submodule in the repo. Swagger requires path parameter to be "required", + // you can leave it empty or pass a single dot (".") to get the root directory. // type: string // required: true // - name: ref @@ -823,7 +824,8 @@ func GetContentsExt(ctx *context.APIContext) { // - name: includes // in: query // description: By default this API's response only contains file's metadata. Use comma-separated "includes" options to retrieve more fields. - // Option "file_content" will try to retrieve the file content, option "lfs_metadata" will try to retrieve LFS metadata. + // Option "file_content" will try to retrieve the file content, "lfs_metadata" will try to retrieve LFS metadata, + // "commit_metadata" will try to retrieve commit metadata, and "commit_message" will try to retrieve commit message. // type: string // required: false // responses: @@ -832,6 +834,9 @@ func GetContentsExt(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" + if treePath := ctx.PathParam("*"); treePath == "." || treePath == "/" { + ctx.SetPathParam("*", "") // workaround for swagger, it requires path parameter to be "required", but we need to list root directory + } opts := files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*")} for includeOpt := range strings.SplitSeq(ctx.FormString("includes"), ",") { if includeOpt == "" { @@ -842,6 +847,10 @@ func GetContentsExt(ctx *context.APIContext) { opts.IncludeSingleFileContent = true case "lfs_metadata": opts.IncludeLfsMetadata = true + case "commit_metadata": + opts.IncludeCommitMetadata = true + case "commit_message": + opts.IncludeCommitMessage = true default: ctx.APIError(http.StatusBadRequest, fmt.Sprintf("unknown include option %q", includeOpt)) return @@ -883,7 +892,11 @@ func GetContents(ctx *context.APIContext) { // "$ref": "#/responses/ContentsResponse" // "404": // "$ref": "#/responses/notFound" - ret := getRepoContents(ctx, files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*"), IncludeSingleFileContent: true}) + ret := getRepoContents(ctx, files_service.GetContentsOrListOptions{ + TreePath: ctx.PathParam("*"), + IncludeSingleFileContent: true, + IncludeCommitMetadata: true, + }) if ctx.Written() { return } diff --git a/services/repository/files/content.go b/services/repository/files/content.go index beef381694..2c1e88bb59 100644 --- a/services/repository/files/content.go +++ b/services/repository/files/content.go @@ -39,6 +39,8 @@ type GetContentsOrListOptions struct { TreePath string IncludeSingleFileContent bool // include the file's content when the tree path is a file IncludeLfsMetadata bool + IncludeCommitMetadata bool + IncludeCommitMessage bool } // GetContentsOrList gets the metadata of a file's contents (*ContentsResponse) if treePath not a tree @@ -132,39 +134,46 @@ func getFileContentsByEntryInternal(_ context.Context, repo *repo_model.Reposito } selfURLString := selfURL.String() - err = gitRepo.AddLastCommitCache(repo.GetCommitsCountCacheKey(refCommit.InputRef, refType != git.RefTypeCommit), repo.FullName(), refCommit.CommitID) - if err != nil { - return nil, err - } - - lastCommit, err := refCommit.Commit.GetCommitByPath(opts.TreePath) - if err != nil { - return nil, err - } - // All content types have these fields in populated contentsResponse := &api.ContentsResponse{ - Name: entry.Name(), - Path: opts.TreePath, - SHA: entry.ID.String(), - LastCommitSHA: lastCommit.ID.String(), - Size: entry.Size(), - URL: &selfURLString, + Name: entry.Name(), + Path: opts.TreePath, + SHA: entry.ID.String(), + Size: entry.Size(), + URL: &selfURLString, Links: &api.FileLinksResponse{ Self: &selfURLString, }, } - // GitHub doesn't have these fields in the response, but we could follow other similar APIs to name them - // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits - if lastCommit.Committer != nil { - contentsResponse.LastCommitterDate = lastCommit.Committer.When - } - if lastCommit.Author != nil { - contentsResponse.LastAuthorDate = lastCommit.Author.When + if opts.IncludeCommitMetadata || opts.IncludeCommitMessage { + err = gitRepo.AddLastCommitCache(repo.GetCommitsCountCacheKey(refCommit.InputRef, refType != git.RefTypeCommit), repo.FullName(), refCommit.CommitID) + if err != nil { + return nil, err + } + + lastCommit, err := refCommit.Commit.GetCommitByPath(opts.TreePath) + if err != nil { + return nil, err + } + + if opts.IncludeCommitMetadata { + contentsResponse.LastCommitSHA = util.ToPointer(lastCommit.ID.String()) + // GitHub doesn't have these fields in the response, but we could follow other similar APIs to name them + // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits + if lastCommit.Committer != nil { + contentsResponse.LastCommitterDate = util.ToPointer(lastCommit.Committer.When) + } + if lastCommit.Author != nil { + contentsResponse.LastAuthorDate = util.ToPointer(lastCommit.Author.When) + } + } + if opts.IncludeCommitMessage { + contentsResponse.LastCommitMessage = util.ToPointer(lastCommit.Message()) + } } - // Now populate the rest of the ContentsResponse based on entry type + // Now populate the rest of the ContentsResponse based on the entry type if entry.IsRegular() || entry.IsExecutable() { contentsResponse.Type = string(ContentTypeRegular) // if it is listing the repo root dir, don't waste system resources on reading content diff --git a/services/repository/files/content_test.go b/services/repository/files/content_test.go index 9357c52ea8..d72f918074 100644 --- a/services/repository/files/content_test.go +++ b/services/repository/files/content_test.go @@ -5,56 +5,21 @@ package files import ( "testing" - "time" "code.gitea.io/gitea/models/unittest" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/contexttest" _ "code.gitea.io/gitea/models/actions" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestMain(m *testing.M) { unittest.MainTest(m) } -func getExpectedReadmeContentsResponse() *api.ContentsResponse { - treePath := "README.md" - sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f" - encoding := "base64" - content := "IyByZXBvMQoKRGVzY3JpcHRpb24gZm9yIHJlcG8x" - selfURL := "https://try.gitea.io/api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master" - htmlURL := "https://try.gitea.io/user2/repo1/src/branch/master/" + treePath - gitURL := "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/" + sha - downloadURL := "https://try.gitea.io/user2/repo1/raw/branch/master/" + treePath - return &api.ContentsResponse{ - Name: treePath, - Path: treePath, - SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", - LastCommitSHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", - LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), - LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), - Type: "file", - Size: 30, - Encoding: &encoding, - Content: &content, - URL: &selfURL, - HTMLURL: &htmlURL, - GitURL: &gitURL, - DownloadURL: &downloadURL, - Links: &api.FileLinksResponse{ - Self: &selfURL, - GitURL: &gitURL, - HTMLURL: &htmlURL, - }, - } -} - func TestGetContents(t *testing.T) { unittest.PrepareTestEnv(t) ctx, _ := contexttest.MockContext(t, "user2/repo1") @@ -63,45 +28,8 @@ func TestGetContents(t *testing.T) { contexttest.LoadRepoCommit(t, ctx) contexttest.LoadUser(t, ctx, 2) contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - repo, gitRepo := ctx.Repo.Repository, ctx.Repo.GitRepo - refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch) - require.NoError(t, err) - t.Run("GetContentsOrList(README.md)-MetaOnly", func(t *testing.T) { - expectedContentsResponse := getExpectedReadmeContentsResponse() - expectedContentsResponse.Encoding = nil // because will be in a list, doesn't have encoding and content - expectedContentsResponse.Content = nil - extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "README.md", IncludeSingleFileContent: false}) - assert.Equal(t, expectedContentsResponse, extResp.FileContents) - assert.NoError(t, err) - }) - - t.Run("GetContentsOrList(README.md)", func(t *testing.T) { - expectedContentsResponse := getExpectedReadmeContentsResponse() - extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "README.md", IncludeSingleFileContent: true}) - assert.Equal(t, expectedContentsResponse, extResp.FileContents) - assert.NoError(t, err) - }) - - t.Run("GetContentsOrList(RootDir)", func(t *testing.T) { - readmeContentsResponse := getExpectedReadmeContentsResponse() - readmeContentsResponse.Encoding = nil // because will be in a list, doesn't have encoding and content - readmeContentsResponse.Content = nil - expectedContentsListResponse := []*api.ContentsResponse{readmeContentsResponse} - // even if IncludeFileContent is true, it has no effect for directory listing - extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "", IncludeSingleFileContent: true}) - assert.Equal(t, expectedContentsListResponse, extResp.DirContents) - assert.NoError(t, err) - }) - - t.Run("GetContentsOrList(NoSuchTreePath)", func(t *testing.T) { - extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "no-such/file.md"}) - assert.Error(t, err) - assert.EqualError(t, err, "object does not exist [id: , rel_path: no-such]") - assert.Nil(t, extResp.DirContents) - assert.Nil(t, extResp.FileContents) - }) + // GetContentsOrList's behavior is fully tested in integration tests, so we don't need to test it here. t.Run("GetBlobBySHA", func(t *testing.T) { sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d" diff --git a/services/repository/files/file.go b/services/repository/files/file.go index 2a63a0a5b9..13d171d139 100644 --- a/services/repository/files/file.go +++ b/services/repository/files/file.go @@ -22,7 +22,12 @@ import ( func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, treePaths []string) (files []*api.ContentsResponse) { var size int64 for _, treePath := range treePaths { - fileContents, _ := GetFileContents(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: treePath, IncludeSingleFileContent: true}) // ok if fails, then will be nil + // ok if fails, then will be nil + fileContents, _ := GetFileContents(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{ + TreePath: treePath, + IncludeSingleFileContent: true, + IncludeCommitMetadata: true, + }) if fileContents != nil && fileContents.Content != nil && *fileContents.Content != "" { // if content isn't empty (e.g., due to the single blob being too large), add file size to response size size += int64(len(*fileContents.Content)) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index ffac8edec2..879f59df2e 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7547,7 +7547,7 @@ }, { "type": "string", - "description": "path of the dir, file, symlink or submodule in the repo", + "description": "path of the dir, file, symlink or submodule in the repo. Swagger requires path parameter to be \"required\", you can leave it empty or pass a single dot (\".\") to get the root directory.", "name": "filepath", "in": "path", "required": true @@ -7560,7 +7560,7 @@ }, { "type": "string", - "description": "By default this API's response only contains file's metadata. Use comma-separated \"includes\" options to retrieve more fields. Option \"file_content\" will try to retrieve the file content, option \"lfs_metadata\" will try to retrieve LFS metadata.", + "description": "By default this API's response only contains file's metadata. Use comma-separated \"includes\" options to retrieve more fields. Option \"file_content\" will try to retrieve the file content, \"lfs_metadata\" will try to retrieve LFS metadata, \"commit_metadata\" will try to retrieve commit metadata, and \"commit_message\" will try to retrieve commit message.", "name": "includes", "in": "query" } @@ -22368,6 +22368,10 @@ "format": "date-time", "x-go-name": "LastAuthorDate" }, + "last_commit_message": { + "type": "string", + "x-go-name": "LastCommitMessage" + }, "last_commit_sha": { "type": "string", "x-go-name": "LastCommitSHA" diff --git a/tests/integration/api_repo_file_create_test.go b/tests/integration/api_repo_file_create_test.go index df0fc3dd05..af3bc54680 100644 --- a/tests/integration/api_repo_file_create_test.go +++ b/tests/integration/api_repo_file_create_test.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" "github.com/stretchr/testify/assert" @@ -52,8 +53,8 @@ func getCreateFileOptions() api.CreateFileOptions { func normalizeFileContentResponseCommitTime(c *api.ContentsResponse) { // decoded JSON response may contain different timezone from the one parsed by git commit // so we need to normalize the time to UTC to make "assert.Equal" pass - c.LastCommitterDate = c.LastCommitterDate.UTC() - c.LastAuthorDate = c.LastAuthorDate.UTC() + c.LastCommitterDate = util.ToPointer(c.LastCommitterDate.UTC()) + c.LastAuthorDate = util.ToPointer(c.LastAuthorDate.UTC()) } type apiFileResponseInfo struct { @@ -74,9 +75,9 @@ func getExpectedFileResponseForCreate(info apiFileResponseInfo) *api.FileRespons Name: path.Base(info.treePath), Path: info.treePath, SHA: sha, - LastCommitSHA: info.lastCommitSHA, - LastCommitterDate: info.lastCommitterWhen, - LastAuthorDate: info.lastAuthorWhen, + LastCommitSHA: util.ToPointer(info.lastCommitSHA), + LastCommitterDate: util.ToPointer(info.lastCommitterWhen), + LastAuthorDate: util.ToPointer(info.lastAuthorWhen), Size: 16, Type: "file", Encoding: &encoding, diff --git a/tests/integration/api_repo_file_update_test.go b/tests/integration/api_repo_file_update_test.go index 6a7f7529a0..9a56711da6 100644 --- a/tests/integration/api_repo_file_update_test.go +++ b/tests/integration/api_repo_file_update_test.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" "github.com/stretchr/testify/assert" @@ -60,9 +61,9 @@ func getExpectedFileResponseForUpdate(info apiFileResponseInfo) *api.FileRespons Name: path.Base(info.treePath), Path: info.treePath, SHA: sha, - LastCommitSHA: info.lastCommitSHA, - LastCommitterDate: info.lastCommitterWhen, - LastAuthorDate: info.lastAuthorWhen, + LastCommitSHA: util.ToPointer(info.lastCommitSHA), + LastCommitterDate: util.ToPointer(info.lastCommitterWhen), + LastAuthorDate: util.ToPointer(info.lastAuthorWhen), Type: "file", Size: 20, Encoding: &encoding, diff --git a/tests/integration/api_repo_get_contents_list_test.go b/tests/integration/api_repo_get_contents_list_test.go index 9b192c6304..563d6fcc10 100644 --- a/tests/integration/api_repo_get_contents_list_test.go +++ b/tests/integration/api_repo_get_contents_list_test.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" repo_service "code.gitea.io/gitea/services/repository" "github.com/stretchr/testify/assert" @@ -35,9 +36,9 @@ func getExpectedContentsListResponseForContents(ref, refType, lastCommitSHA stri Name: path.Base(treePath), Path: treePath, SHA: sha, - LastCommitSHA: lastCommitSHA, - LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), - LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), + LastCommitSHA: util.ToPointer(lastCommitSHA), + LastCommitterDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))), + LastAuthorDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))), Type: "file", Size: 30, URL: &selfURL, @@ -65,7 +66,6 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) { repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo - treePath := "" // root dir // Get user2's token session := loginUser(t, user2.Name) @@ -94,7 +94,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) { // ref is default ref ref := repo1.DefaultBranch refType := "branch" - req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents?ref=%s", user2.Name, repo1.Name, ref) resp := MakeRequest(t, req, http.StatusOK) var contentsListResponse []*api.ContentsResponse DecodeJSON(t, resp, &contentsListResponse) @@ -106,7 +106,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) { // No ref refType = "branch" - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath) + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", user2.Name, repo1.Name) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &contentsListResponse) assert.NotNil(t, contentsListResponse) @@ -117,7 +117,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) { // ref is the branch we created above in setup ref = newBranch refType = "branch" - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents?ref=%s", user2.Name, repo1.Name, ref) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &contentsListResponse) assert.NotNil(t, contentsListResponse) @@ -131,7 +131,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) { // ref is the new tag we created above in setup ref = newTag refType = "tag" - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/?ref=%s", user2.Name, repo1.Name, ref) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &contentsListResponse) assert.NotNil(t, contentsListResponse) @@ -145,7 +145,7 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) { // ref is a commit ref = commitID refType = "commit" - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/?ref=%s", user2.Name, repo1.Name, ref) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &contentsListResponse) assert.NotNil(t, contentsListResponse) @@ -154,21 +154,21 @@ func testAPIGetContentsList(t *testing.T, u *url.URL) { // Test file contents a file with a bad ref ref = "badref" - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/?ref=%s", user2.Name, repo1.Name, ref) MakeRequest(t, req, http.StatusNotFound) // Test accessing private ref with user token that does not have access - should fail - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath). + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", user2.Name, repo16.Name). AddTokenAuth(token4) MakeRequest(t, req, http.StatusNotFound) // Test access private ref of owner of token - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md", user2.Name, repo16.Name). + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", user2.Name, repo16.Name). AddTokenAuth(token2) MakeRequest(t, req, http.StatusOK) // Test access of org org3 private repo file by owner user2 - req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath). + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/", org3.Name, repo3.Name). AddTokenAuth(token2) MakeRequest(t, req, http.StatusOK) } diff --git a/tests/integration/api_repo_get_contents_test.go b/tests/integration/api_repo_get_contents_test.go index 9517db4c87..33df74f6ee 100644 --- a/tests/integration/api_repo_get_contents_test.go +++ b/tests/integration/api_repo_get_contents_test.go @@ -35,9 +35,9 @@ func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string) Name: treePath, Path: treePath, SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", - LastCommitSHA: lastCommitSHA, - LastCommitterDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), - LastAuthorDate: time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400)), + LastCommitSHA: util.ToPointer(lastCommitSHA), + LastCommitterDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))), + LastAuthorDate: util.ToPointer(time.Date(2017, time.March, 19, 16, 47, 59, 0, time.FixedZone("", -14400))), Type: "file", Size: 30, Encoding: util.ToPointer("base64"), @@ -97,11 +97,16 @@ func testAPIGetContents(t *testing.T, u *url.URL) { require.NoError(t, err) /*** END SETUP ***/ + // not found + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/no-such/file.md", user2.Name, repo1.Name) + resp := MakeRequest(t, req, http.StatusNotFound) + assert.Contains(t, resp.Body.String(), "object does not exist [id: , rel_path: no-such]") + // ref is default ref ref := repo1.DefaultBranch refType := "branch" - req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) - resp := MakeRequest(t, req, http.StatusOK) + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + resp = MakeRequest(t, req, http.StatusOK) var contentsResponse api.ContentsResponse DecodeJSON(t, resp, &contentsResponse) lastCommit, _ := gitRepo.GetCommitByPath("README.md") @@ -116,7 +121,7 @@ func testAPIGetContents(t *testing.T, u *url.URL) { expectedContentsResponse = getExpectedContentsResponseForContents(repo1.DefaultBranch, refType, lastCommit.ID.String()) assert.Equal(t, *expectedContentsResponse, contentsResponse) - // ref is the branch we created above in setup + // ref is the branch we created above in setup ref = newBranch refType = "branch" req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) @@ -206,14 +211,30 @@ func testAPIGetContentsExt(t *testing.T) { session := loginUser(t, "user2") token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) t.Run("DirContents", func(t *testing.T) { - req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check") + req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext?ref=sub-home-md-img-check") resp := MakeRequest(t, req, http.StatusOK) var contentsResponse api.ContentsExtResponse DecodeJSON(t, resp, &contentsResponse) assert.Nil(t, contentsResponse.FileContents) + assert.NotNil(t, contentsResponse.DirContents) + + req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/.?ref=sub-home-md-img-check") + resp = MakeRequest(t, req, http.StatusOK) + contentsResponse = api.ContentsExtResponse{} + DecodeJSON(t, resp, &contentsResponse) + assert.Nil(t, contentsResponse.FileContents) + assert.NotNil(t, contentsResponse.DirContents) + + req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check") + resp = MakeRequest(t, req, http.StatusOK) + contentsResponse = api.ContentsExtResponse{} + DecodeJSON(t, resp, &contentsResponse) + assert.Nil(t, contentsResponse.FileContents) assert.Equal(t, "README.md", contentsResponse.DirContents[0].Name) assert.Nil(t, contentsResponse.DirContents[0].Encoding) assert.Nil(t, contentsResponse.DirContents[0].Content) + assert.Nil(t, contentsResponse.DirContents[0].LastCommitSHA) + assert.Nil(t, contentsResponse.DirContents[0].LastCommitMessage) // "includes=file_content" shouldn't affect directory listing req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check&includes=file_content") @@ -240,7 +261,7 @@ func testAPIGetContentsExt(t *testing.T) { assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid) }) t.Run("FileContents", func(t *testing.T) { - // by default, no file content is returned + // by default, no file content or commit info is returned req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check") resp := MakeRequest(t, req, http.StatusOK) var contentsResponse api.ContentsExtResponse @@ -249,9 +270,11 @@ func testAPIGetContentsExt(t *testing.T) { assert.Equal(t, "README.md", contentsResponse.FileContents.Name) assert.Nil(t, contentsResponse.FileContents.Encoding) assert.Nil(t, contentsResponse.FileContents.Content) + assert.Nil(t, contentsResponse.FileContents.LastCommitSHA) + assert.Nil(t, contentsResponse.FileContents.LastCommitMessage) // file content is only returned when `includes=file_content` - req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check&includes=file_content") + req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check&includes=file_content,commit_metadata,commit_message") resp = MakeRequest(t, req, http.StatusOK) contentsResponse = api.ContentsExtResponse{} DecodeJSON(t, resp, &contentsResponse) @@ -259,6 +282,8 @@ func testAPIGetContentsExt(t *testing.T) { assert.Equal(t, "README.md", contentsResponse.FileContents.Name) assert.NotNil(t, contentsResponse.FileContents.Encoding) assert.NotNil(t, contentsResponse.FileContents.Content) + assert.Equal(t, "4649299398e4d39a5c09eb4f534df6f1e1eb87cc", *contentsResponse.FileContents.LastCommitSHA) + assert.Equal(t, "Test how READMEs render images when found in a subfolder\n", *contentsResponse.FileContents.LastCommitMessage) req = NewRequestf(t, "GET", "/api/v1/repos/user2/lfs/contents-ext/jpeg.jpg?includes=file_content").AddTokenAuth(token2) resp = session.MakeRequest(t, req, http.StatusOK) @@ -270,6 +295,8 @@ func testAPIGetContentsExt(t *testing.T) { assert.Equal(t, "jpeg.jpg", respFile.Name) assert.NotNil(t, respFile.Encoding) assert.NotNil(t, respFile.Content) + assert.Nil(t, contentsResponse.FileContents.LastCommitSHA) + assert.Nil(t, contentsResponse.FileContents.LastCommitMessage) assert.Equal(t, util.ToPointer(int64(107)), respFile.LfsSize) assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid) }) diff --git a/tests/integration/repofiles_change_test.go b/tests/integration/repofiles_change_test.go index b63d06a866..dc389f5680 100644 --- a/tests/integration/repofiles_change_test.go +++ b/tests/integration/repofiles_change_test.go @@ -155,9 +155,9 @@ func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git. Name: path.Base(treePath), Path: treePath, SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", - LastCommitSHA: lastCommit.ID.String(), - LastCommitterDate: lastCommit.Committer.When, - LastAuthorDate: lastCommit.Author.When, + LastCommitSHA: util.ToPointer(lastCommit.ID.String()), + LastCommitterDate: util.ToPointer(lastCommit.Committer.When), + LastAuthorDate: util.ToPointer(lastCommit.Author.When), Type: "file", Size: 18, Encoding: &encoding, @@ -198,7 +198,7 @@ func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git. SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", }, }, - Message: "Updates README.md\n", + Message: "Creates new/file.txt\n", Tree: &api.CommitMeta{ URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc", SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc", @@ -225,9 +225,9 @@ func getExpectedFileResponseForRepoFilesUpdate(commitID, filename, lastCommitSHA Name: filename, Path: filename, SHA: "dbf8d00e022e05b7e5cf7e535de857de57925647", - LastCommitSHA: lastCommitSHA, - LastCommitterDate: lastCommitterWhen, - LastAuthorDate: lastAuthorWhen, + LastCommitSHA: util.ToPointer(lastCommitSHA), + LastCommitterDate: util.ToPointer(lastCommitterWhen), + LastAuthorDate: util.ToPointer(lastAuthorWhen), Type: "file", Size: 43, Encoding: &encoding, @@ -331,7 +331,7 @@ func getExpectedFileResponseForRepoFilesUpdateRename(commitID, lastCommitSHA str Name: detail.filename, Path: detail.filename, SHA: detail.sha, - LastCommitSHA: lastCommitSHA, + LastCommitSHA: util.ToPointer(lastCommitSHA), Type: "file", Size: detail.size, Encoding: util.ToPointer("base64"), @@ -537,7 +537,7 @@ func TestChangeRepoFilesForUpdateWithFileRename(t *testing.T) { lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath) expectedFileResponse := getExpectedFileResponseForRepoFilesUpdateRename(commit.ID.String(), lastCommit.ID.String()) for _, file := range filesResponse.Files { - file.LastCommitterDate, file.LastAuthorDate = time.Time{}, time.Time{} // there might be different time in one operation, so we ignore them + file.LastCommitterDate, file.LastAuthorDate = nil, nil // there might be different time in one operation, so we ignore them } assert.Len(t, filesResponse.Files, 4) assert.Equal(t, expectedFileResponse.Files, filesResponse.Files) From 8cbec63cc70f9ebbe1558123d95cbe63b2f31782 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 3 Jul 2025 10:35:45 +0800 Subject: [PATCH 13/34] Don't send trigger for a pending review's comment create/update/delete (#34928) Fix #18846 Fix #34924 --------- Co-authored-by: wxiaoguang --- models/issues/comment.go | 10 +++------- services/notify/notify.go | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/models/issues/comment.go b/models/issues/comment.go index 9bef96d0dd..4fdb0c1808 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -715,7 +715,8 @@ func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository return nil } -func (c *Comment) loadReview(ctx context.Context) (err error) { +// LoadReview loads the associated review +func (c *Comment) LoadReview(ctx context.Context) (err error) { if c.ReviewID == 0 { return nil } @@ -732,11 +733,6 @@ func (c *Comment) loadReview(ctx context.Context) (err error) { return nil } -// LoadReview loads the associated review -func (c *Comment) LoadReview(ctx context.Context) error { - return c.loadReview(ctx) -} - // DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes. func (c *Comment) DiffSide() string { if c.Line < 0 { @@ -856,7 +852,7 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment } if comment.ReviewID != 0 { if comment.Review == nil { - if err := comment.loadReview(ctx); err != nil { + if err := comment.LoadReview(ctx); err != nil { return err } } diff --git a/services/notify/notify.go b/services/notify/notify.go index 0c6fdf9cef..2416cbd2e0 100644 --- a/services/notify/notify.go +++ b/services/notify/notify.go @@ -46,10 +46,25 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model } } +func shouldSendCommentChangeNotification(ctx context.Context, comment *issues_model.Comment) bool { + if err := comment.LoadReview(ctx); err != nil { + log.Error("LoadReview: %v", err) + return false + } else if comment.Review != nil && comment.Review.Type == issues_model.ReviewTypePending { + // Pending review comments updating should not triggered + return false + } + return true +} + // CreateIssueComment notifies issue comment related message to notifiers func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, ) { + if !shouldSendCommentChangeNotification(ctx, comment) { + return + } + for _, notifier := range notifiers { notifier.CreateIssueComment(ctx, doer, repo, issue, comment, mentions) } @@ -156,6 +171,10 @@ func PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issue // UpdateComment notifies update comment to notifiers func UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) { + if !shouldSendCommentChangeNotification(ctx, c) { + return + } + for _, notifier := range notifiers { notifier.UpdateComment(ctx, doer, c, oldContent) } @@ -163,6 +182,10 @@ func UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.C // DeleteComment notifies delete comment to notifiers func DeleteComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment) { + if !shouldSendCommentChangeNotification(ctx, c) { + return + } + for _, notifier := range notifiers { notifier.DeleteComment(ctx, doer, c) } From d6d643fe86f125ee7fdda82264602b7e8db2a36b Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 3 Jul 2025 11:02:38 +0800 Subject: [PATCH 14/34] Fix http auth header parsing (#34936) Using `strings.EqualFold` is wrong in many cases. --- modules/auth/httpauth/httpauth.go | 47 ++++++++++++++++++++++++++ modules/auth/httpauth/httpauth_test.go | 43 +++++++++++++++++++++++ modules/base/tool.go | 16 --------- modules/base/tool_test.go | 19 ----------- modules/util/string.go | 21 ++++++++++++ routers/web/auth/oauth2_provider.go | 29 ++++++---------- services/auth/basic.go | 15 ++++---- services/auth/oauth2.go | 7 ++-- services/lfs/server.go | 17 +++------- 9 files changed, 136 insertions(+), 78 deletions(-) create mode 100644 modules/auth/httpauth/httpauth.go create mode 100644 modules/auth/httpauth/httpauth_test.go diff --git a/modules/auth/httpauth/httpauth.go b/modules/auth/httpauth/httpauth.go new file mode 100644 index 0000000000..7f1f1ee152 --- /dev/null +++ b/modules/auth/httpauth/httpauth.go @@ -0,0 +1,47 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package httpauth + +import ( + "encoding/base64" + "strings" + + "code.gitea.io/gitea/modules/util" +) + +type BasicAuth struct { + Username, Password string +} + +type BearerToken struct { + Token string +} + +type ParsedAuthorizationHeader struct { + BasicAuth *BasicAuth + BearerToken *BearerToken +} + +func ParseAuthorizationHeader(header string) (ret ParsedAuthorizationHeader, _ bool) { + parts := strings.Fields(header) + if len(parts) != 2 { + return ret, false + } + if util.AsciiEqualFold(parts[0], "basic") { + s, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return ret, false + } + u, p, ok := strings.Cut(string(s), ":") + if !ok { + return ret, false + } + ret.BasicAuth = &BasicAuth{Username: u, Password: p} + return ret, true + } else if util.AsciiEqualFold(parts[0], "token") || util.AsciiEqualFold(parts[0], "bearer") { + ret.BearerToken = &BearerToken{Token: parts[1]} + return ret, true + } + return ret, false +} diff --git a/modules/auth/httpauth/httpauth_test.go b/modules/auth/httpauth/httpauth_test.go new file mode 100644 index 0000000000..087b86917f --- /dev/null +++ b/modules/auth/httpauth/httpauth_test.go @@ -0,0 +1,43 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package httpauth + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseAuthorizationHeader(t *testing.T) { + type parsed = ParsedAuthorizationHeader + type basic = BasicAuth + type bearer = BearerToken + cases := []struct { + headerValue string + expected parsed + ok bool + }{ + {"", parsed{}, false}, + {"?", parsed{}, false}, + {"foo", parsed{}, false}, + {"any value", parsed{}, false}, + + {"Basic ?", parsed{}, false}, + {"Basic " + base64.StdEncoding.EncodeToString([]byte("foo")), parsed{}, false}, + {"Basic " + base64.StdEncoding.EncodeToString([]byte("foo:bar")), parsed{BasicAuth: &basic{"foo", "bar"}}, true}, + {"basic " + base64.StdEncoding.EncodeToString([]byte("foo:bar")), parsed{BasicAuth: &basic{"foo", "bar"}}, true}, + + {"token value", parsed{BearerToken: &bearer{"value"}}, true}, + {"Token value", parsed{BearerToken: &bearer{"value"}}, true}, + {"bearer value", parsed{BearerToken: &bearer{"value"}}, true}, + {"Bearer value", parsed{BearerToken: &bearer{"value"}}, true}, + {"Bearer wrong value", parsed{}, false}, + } + for _, c := range cases { + ret, ok := ParseAuthorizationHeader(c.headerValue) + assert.Equal(t, c.ok, ok, "header %q", c.headerValue) + assert.Equal(t, c.expected, ret, "header %q", c.headerValue) + } +} diff --git a/modules/base/tool.go b/modules/base/tool.go index 02ca85569e..ed94575e74 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -8,13 +8,10 @@ import ( "crypto/sha1" "crypto/sha256" "crypto/subtle" - "encoding/base64" "encoding/hex" - "errors" "fmt" "hash" "strconv" - "strings" "time" "code.gitea.io/gitea/modules/setting" @@ -36,19 +33,6 @@ func ShortSha(sha1 string) string { return util.TruncateRunes(sha1, 10) } -// BasicAuthDecode decode basic auth string -func BasicAuthDecode(encoded string) (string, string, error) { - s, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return "", "", err - } - - if username, password, ok := strings.Cut(string(s), ":"); ok { - return username, password, nil - } - return "", "", errors.New("invalid basic authentication") -} - // VerifyTimeLimitCode verify time limit code func VerifyTimeLimitCode(now time.Time, data string, minutes int, code string) bool { if len(code) <= 18 { diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go index 7cebedb073..b7365e40c4 100644 --- a/modules/base/tool_test.go +++ b/modules/base/tool_test.go @@ -26,25 +26,6 @@ func TestShortSha(t *testing.T) { assert.Equal(t, "veryverylo", ShortSha("veryverylong")) } -func TestBasicAuthDecode(t *testing.T) { - _, _, err := BasicAuthDecode("?") - assert.Equal(t, "illegal base64 data at input byte 0", err.Error()) - - user, pass, err := BasicAuthDecode("Zm9vOmJhcg==") - assert.NoError(t, err) - assert.Equal(t, "foo", user) - assert.Equal(t, "bar", pass) - - _, _, err = BasicAuthDecode("aW52YWxpZA==") - assert.Error(t, err) - - _, _, err = BasicAuthDecode("invalid") - assert.Error(t, err) - - _, _, err = BasicAuthDecode("YWxpY2U=") // "alice", no colon - assert.Error(t, err) -} - func TestVerifyTimeLimitCode(t *testing.T) { defer test.MockVariableValue(&setting.InstallLock, true)() initGeneralSecret := func(secret string) { diff --git a/modules/util/string.go b/modules/util/string.go index 03c0df96a3..b9b59df3ef 100644 --- a/modules/util/string.go +++ b/modules/util/string.go @@ -110,3 +110,24 @@ func SplitTrimSpace(input, sep string) []string { } return stringList } + +func asciiLower(b byte) byte { + if 'A' <= b && b <= 'Z' { + return b + ('a' - 'A') + } + return b +} + +// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go +// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold] +func AsciiEqualFold(s, t string) bool { //nolint:revive // PascalCase + if len(s) != len(t) { + return false + } + for i := 0; i < len(s); i++ { + if asciiLower(s[i]) != asciiLower(t[i]) { + return false + } + } + return true +} diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go index 1804b5b193..dc9f34fd44 100644 --- a/routers/web/auth/oauth2_provider.go +++ b/routers/web/auth/oauth2_provider.go @@ -4,18 +4,16 @@ package auth import ( - "errors" "fmt" "html" "html/template" "net/http" "net/url" "strconv" - "strings" "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/auth/httpauth" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -108,9 +106,8 @@ func InfoOAuth(ctx *context.Context) { var accessTokenScope auth.AccessTokenScope if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" { - auths := strings.Fields(auHead) - if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") { - accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1]) + if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil { + accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token) } } @@ -127,18 +124,12 @@ func InfoOAuth(ctx *context.Context) { ctx.JSON(http.StatusOK, response) } -func parseBasicAuth(ctx *context.Context) (username, password string, err error) { - authHeader := ctx.Req.Header.Get("Authorization") - if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { - return base.BasicAuthDecode(authData) - } - return "", "", errors.New("invalid basic authentication") -} - // IntrospectOAuth introspects an oauth token func IntrospectOAuth(ctx *context.Context) { clientIDValid := false - if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil { + authHeader := ctx.Req.Header.Get("Authorization") + if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil { + clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID) if err != nil && !auth.IsErrOauthClientIDInvalid(err) { // this is likely a database error; log it and respond without details @@ -465,16 +456,16 @@ func AccessTokenOAuth(ctx *context.Context) { form := *web.GetForm(ctx).(*forms.AccessTokenForm) // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header if form.ClientID == "" || form.ClientSecret == "" { - authHeader := ctx.Req.Header.Get("Authorization") - if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { - clientID, clientSecret, err := base.BasicAuthDecode(authData) - if err != nil { + if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" { + parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) + if !ok || parsed.BasicAuth == nil { handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest, ErrorDescription: "cannot parse basic auth header", }) return } + clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password // validate that any fields present in the form match the Basic auth header if form.ClientID != "" && form.ClientID != clientID { handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ diff --git a/services/auth/basic.go b/services/auth/basic.go index a208590d7b..b2bd14ef5d 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -7,12 +7,11 @@ package auth import ( "errors" "net/http" - "strings" actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/auth/httpauth" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -54,17 +53,15 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore return nil, nil } - baHead := req.Header.Get("Authorization") - if len(baHead) == 0 { + authHeader := req.Header.Get("Authorization") + if authHeader == "" { return nil, nil } - - auths := strings.SplitN(baHead, " ", 2) - if len(auths) != 2 || (strings.ToLower(auths[0]) != "basic") { + parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) + if !ok || parsed.BasicAuth == nil { return nil, nil } - - uname, passwd, _ := base.BasicAuthDecode(auths[1]) + uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password // Check if username or password is a token isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic" diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 66cc686809..7df6f4638e 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -13,6 +13,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/httpauth" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -97,9 +98,9 @@ func parseToken(req *http.Request) (string, bool) { // check header token if auHead := req.Header.Get("Authorization"); auHead != "" { - auths := strings.Fields(auHead) - if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") { - return auths[1], true + parsed, ok := httpauth.ParseAuthorizationHeader(auHead) + if ok && parsed.BearerToken != nil { + return parsed.BearerToken.Token, true } } return "", false diff --git a/services/lfs/server.go b/services/lfs/server.go index 15a51ad534..c44cc35e53 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -27,6 +27,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/httpauth" "code.gitea.io/gitea/modules/json" lfs_module "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" @@ -594,19 +595,11 @@ func parseToken(ctx stdCtx.Context, authorization string, target *repo_model.Rep if authorization == "" { return nil, errors.New("no token") } - - parts := strings.SplitN(authorization, " ", 2) - if len(parts) != 2 { - return nil, errors.New("no token") + parsed, ok := httpauth.ParseAuthorizationHeader(authorization) + if !ok || parsed.BearerToken == nil { + return nil, errors.New("token not found") } - tokenSHA := parts[1] - switch strings.ToLower(parts[0]) { - case "bearer": - fallthrough - case "token": - return handleLFSToken(ctx, tokenSHA, target, mode) - } - return nil, errors.New("token not found") + return handleLFSToken(ctx, parsed.BearerToken.Token, target, mode) } func requireAuth(ctx *context.Context) { From 71e151cc2244c05c616a8923e0d6299c76c954b2 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 4 Jul 2025 19:03:22 +0800 Subject: [PATCH 15/34] Refactor head navbar icons (#34922) Co-authored-by: silverwind Co-authored-by: Giteabot --- routers/common/pagetmpl.go | 28 +++++--- routers/web/web.go | 2 +- templates/base/head_navbar.tmpl | 53 +------------- templates/base/head_navbar_icons.tmpl | 25 +++++++ templates/swagger/ui.tmpl | 1 + .../user/notification/notification_div.tmpl | 2 +- tests/integration/integration_test.go | 3 + tests/integration/links_test.go | 69 +++++++++---------- web_src/css/modules/navbar.css | 13 ---- 9 files changed, 83 insertions(+), 113 deletions(-) create mode 100644 templates/base/head_navbar_icons.tmpl diff --git a/routers/common/pagetmpl.go b/routers/common/pagetmpl.go index 52c9fceba3..c48596d48b 100644 --- a/routers/common/pagetmpl.go +++ b/routers/common/pagetmpl.go @@ -6,6 +6,7 @@ package common import ( goctx "context" "errors" + "sync" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" @@ -22,8 +23,7 @@ type StopwatchTmplInfo struct { Seconds int64 } -func getActiveStopwatch(goCtx goctx.Context) *StopwatchTmplInfo { - ctx := context.GetWebContext(goCtx) +func getActiveStopwatch(ctx *context.Context) *StopwatchTmplInfo { if ctx.Doer == nil { return nil } @@ -48,8 +48,7 @@ func getActiveStopwatch(goCtx goctx.Context) *StopwatchTmplInfo { } } -func notificationUnreadCount(goCtx goctx.Context) int64 { - ctx := context.GetWebContext(goCtx) +func notificationUnreadCount(ctx *context.Context) int64 { if ctx.Doer == nil { return 0 } @@ -66,10 +65,19 @@ func notificationUnreadCount(goCtx goctx.Context) int64 { return count } -func PageTmplFunctions(ctx *context.Context) { - if ctx.IsSigned { - // defer the function call to the last moment when the tmpl renders - ctx.Data["NotificationUnreadCount"] = notificationUnreadCount - ctx.Data["GetActiveStopwatch"] = getActiveStopwatch - } +type pageGlobalDataType struct { + IsSigned bool + IsSiteAdmin bool + + GetNotificationUnreadCount func() int64 + GetActiveStopwatch func() *StopwatchTmplInfo +} + +func PageGlobalData(ctx *context.Context) { + var data pageGlobalDataType + data.IsSigned = ctx.Doer != nil + data.IsSiteAdmin = ctx.Doer != nil && ctx.Doer.IsAdmin + data.GetNotificationUnreadCount = sync.OnceValue(func() int64 { return notificationUnreadCount(ctx) }) + data.GetActiveStopwatch = sync.OnceValue(func() *StopwatchTmplInfo { return getActiveStopwatch(ctx) }) + ctx.Data["PageGlobalData"] = data } diff --git a/routers/web/web.go b/routers/web/web.go index 1039f9e739..66c3a2da09 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -281,7 +281,7 @@ func Routes() *web.Router { } mid = append(mid, goGet) - mid = append(mid, common.PageTmplFunctions) + mid = append(mid, common.PageGlobalData) webRoutes := web.NewRouter() webRoutes.Use(mid...) diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index dacc518873..b721779c95 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -1,11 +1,3 @@ -{{$notificationUnreadCount := 0}} -{{if and .IsSigned .NotificationUnreadCount}} - {{$notificationUnreadCount = call .NotificationUnreadCount ctx}} -{{end}} -{{$activeStopwatch := NIL}} -{{if and .IsSigned EnableTimetracking .GetActiveStopwatch}} - {{$activeStopwatch = call .GetActiveStopwatch ctx}} -{{end}}
{{else if .IsSigned}} - {{if $activeStopwatch}} - -
- {{svg "octicon-stopwatch"}} - -
-
- {{end}} - - -
- {{svg "octicon-bell"}} - {{$notificationUnreadCount}} -
-
- + {{template "base/head_navbar_icons" dict "ItemExtraClass" "not-mobile" "PageGlobalData" .PageGlobalData}} {{if and $.CanWriteProjects (not $.Repository.IsArchived)}} - {{end}} @@ -92,15 +92,11 @@ {{if or .CanWriteIssues .CanWritePulls}} - {{if .PageIsEditRelease}} -
- {{ctx.Locale.Tr "repo.settings.edit_protected_branch"}} -
@@ -65,14 +65,9 @@ {{end}} - - @@ -95,7 +95,7 @@ {{if $allowedToChangeTeams}}
-
@@ -123,14 +123,9 @@ {{end}} -
-
@@ -72,14 +72,9 @@ - @@ -96,14 +96,9 @@ - {{svg "octicon-pencil"}} - {{svg "octicon-trash"}} + {{svg "octicon-trash"}} {{end}} diff --git a/templates/repo/settings/webhook/delete_modal.tmpl b/templates/repo/settings/webhook/delete_modal.tmpl deleted file mode 100644 index 9955ed3a2f..0000000000 --- a/templates/repo/settings/webhook/delete_modal.tmpl +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/templates/repo/settings/webhook/dingtalk.tmpl b/templates/repo/settings/webhook/dingtalk.tmpl index 0ba99e98ee..dd208cde17 100644 --- a/templates/repo/settings/webhook/dingtalk.tmpl +++ b/templates/repo/settings/webhook/dingtalk.tmpl @@ -6,6 +6,7 @@ - {{template "repo/settings/webhook/settings" .}} + {{/* FIXME: support authorization header or not? */}} + {{template "repo/settings/webhook/settings" dict "BaseLink" .BaseLink "Webhook" .Webhook "UseAuthorizationHeader" "optional"}} {{end}} diff --git a/templates/repo/settings/webhook/discord.tmpl b/templates/repo/settings/webhook/discord.tmpl index 104346e042..fa66249fa5 100644 --- a/templates/repo/settings/webhook/discord.tmpl +++ b/templates/repo/settings/webhook/discord.tmpl @@ -14,6 +14,7 @@ - {{template "repo/settings/webhook/settings" .}} + {{/* FIXME: support authorization header or not? */}} + {{template "repo/settings/webhook/settings" dict "BaseLink" .BaseLink "Webhook" .Webhook "UseAuthorizationHeader" "optional"}} {{end}} diff --git a/templates/repo/settings/webhook/feishu.tmpl b/templates/repo/settings/webhook/feishu.tmpl index d80deab26f..13bd0d92a1 100644 --- a/templates/repo/settings/webhook/feishu.tmpl +++ b/templates/repo/settings/webhook/feishu.tmpl @@ -1,12 +1,14 @@ {{if eq .HookType "feishu"}} -

{{ctx.Locale.Tr "repo.settings.add_web_hook_desc" "https://feishu.cn" (ctx.Locale.Tr "repo.settings.web_hook_name_feishu")}}

-

{{ctx.Locale.Tr "repo.settings.add_web_hook_desc" "https://larksuite.com" (ctx.Locale.Tr "repo.settings.web_hook_name_larksuite")}}

+

+ {{ctx.Locale.Tr "repo.settings.add_web_hook_desc" "https://feishu.cn" (ctx.Locale.Tr "repo.settings.web_hook_name_feishu")}} + {{ctx.Locale.Tr "repo.settings.add_web_hook_desc" "https://larksuite.com" (ctx.Locale.Tr "repo.settings.web_hook_name_larksuite")}} +

{{.CsrfTokenHtml}}
- {{template "repo/settings/webhook/settings" .}} + {{template "repo/settings/webhook/settings" dict "BaseLink" .BaseLink "Webhook" .Webhook "UseRequestSecret" "optional"}}
{{end}} diff --git a/templates/repo/settings/webhook/gitea.tmpl b/templates/repo/settings/webhook/gitea.tmpl index e6eb61ea92..30f14d609b 100644 --- a/templates/repo/settings/webhook/gitea.tmpl +++ b/templates/repo/settings/webhook/gitea.tmpl @@ -31,10 +31,11 @@ -
- - -
- {{template "repo/settings/webhook/settings" .}} + {{template "repo/settings/webhook/settings" dict + "BaseLink" .BaseLink + "Webhook" .Webhook + "UseAuthorizationHeader" "optional" + "UseRequestSecret" "optional" + }} {{end}} diff --git a/templates/repo/settings/webhook/gogs.tmpl b/templates/repo/settings/webhook/gogs.tmpl index e91a3279e4..c0e054602a 100644 --- a/templates/repo/settings/webhook/gogs.tmpl +++ b/templates/repo/settings/webhook/gogs.tmpl @@ -19,10 +19,11 @@ -
- - -
- {{template "repo/settings/webhook/settings" .}} + {{template "repo/settings/webhook/settings" dict + "BaseLink" .BaseLink + "Webhook" .Webhook + "UseAuthorizationHeader" "optional" + "UseRequestSecret" "optional" + }} {{end}} diff --git a/templates/repo/settings/webhook/list.tmpl b/templates/repo/settings/webhook/list.tmpl deleted file mode 100644 index b24159fccb..0000000000 --- a/templates/repo/settings/webhook/list.tmpl +++ /dev/null @@ -1,4 +0,0 @@ - -{{template "repo/settings/webhook/base_list" .}} - -{{template "repo/settings/webhook/delete_modal" .}} diff --git a/templates/repo/settings/webhook/matrix.tmpl b/templates/repo/settings/webhook/matrix.tmpl index 7f1c9f08e6..e0aad2d807 100644 --- a/templates/repo/settings/webhook/matrix.tmpl +++ b/templates/repo/settings/webhook/matrix.tmpl @@ -22,6 +22,6 @@ - {{template "repo/settings/webhook/settings" .}} + {{template "repo/settings/webhook/settings" dict "BaseLink" .BaseLink "Webhook" .Webhook "UseAuthorizationHeader" "required"}} {{end}} diff --git a/templates/repo/settings/webhook/msteams.tmpl b/templates/repo/settings/webhook/msteams.tmpl index 62ea24e763..17718a1064 100644 --- a/templates/repo/settings/webhook/msteams.tmpl +++ b/templates/repo/settings/webhook/msteams.tmpl @@ -6,6 +6,7 @@ - {{template "repo/settings/webhook/settings" .}} + {{/* FIXME: support authorization header or not? */}} + {{template "repo/settings/webhook/settings" dict "BaseLink" .BaseLink "Webhook" .Webhook "UseAuthorizationHeader" "optional"}} {{end}} diff --git a/templates/repo/settings/webhook/packagist.tmpl b/templates/repo/settings/webhook/packagist.tmpl index 25aba2a435..c813e7c2af 100644 --- a/templates/repo/settings/webhook/packagist.tmpl +++ b/templates/repo/settings/webhook/packagist.tmpl @@ -14,6 +14,7 @@ - {{template "repo/settings/webhook/settings" .}} + {{/* FIXME: support authorization header or not? */}} + {{template "repo/settings/webhook/settings" dict "BaseLink" .BaseLink "Webhook" .Webhook "UseAuthorizationHeader" "optional"}} {{end}} diff --git a/templates/repo/settings/webhook/settings.tmpl b/templates/repo/settings/webhook/settings.tmpl index a330448c9e..a8ad1d6c9e 100644 --- a/templates/repo/settings/webhook/settings.tmpl +++ b/templates/repo/settings/webhook/settings.tmpl @@ -1,4 +1,52 @@ -{{$isNew:=or .PageIsSettingsHooksNew .PageIsAdminDefaultHooksNew .PageIsAdminSystemHooksNew}} +{{/* Template attributes: +- BaseLink: Base URL for the repository settings +- WebHook: Webhook object containing details about the webhook +- UseAuthorizationHeader: optional or required +- UseRequestSecret: optional or required +*/}} +{{$isNew := not .Webhook.ID}} + +
+
+ + + {{ctx.Locale.Tr "repo.settings.active_helper"}} +
+
+ + +{{if .UseAuthorizationHeader}} + {{$attributeValid := or (eq .UseAuthorizationHeader "optional") (eq .UseAuthorizationHeader "required")}} + {{if not $attributeValid}}
Invalid UseAuthorizationHeader: {{.UseAuthorizationHeader}}}
{{end}} + {{$required := eq .UseAuthorizationHeader "required"}} +
+ + + {{if not $required}} + {{ctx.Locale.Tr "repo.settings.authorization_header_desc" (HTMLFormat "%s, %s" "Bearer token123456" "Basic YWxhZGRpbjpvcGVuc2VzYW1l")}} + {{end}} +
+{{end}} + + +{{if .UseRequestSecret}} + {{$attributeValid := or (eq .UseRequestSecret "optional") (eq .UseRequestSecret "required")}} + {{if not $attributeValid}}
Invalid UseRequestSecret: {{.UseRequestSecret}}}
{{end}} + {{$required := eq .UseRequestSecret "required"}} +
+ + + {{ctx.Locale.Tr "repo.settings.webhook_secret_desc"}} +
+{{end}} + + +
+ + + {{ctx.Locale.Tr "repo.settings.branch_filter_desc" "https://pkg.go.dev/github.com/gobwas/glob#Compile" "github.com/gobwas/glob"}} +
+

{{ctx.Locale.Tr "repo.settings.event_desc"}}

@@ -286,38 +334,14 @@
- -
- - - {{ctx.Locale.Tr "repo.settings.branch_filter_desc" "https://pkg.go.dev/github.com/gobwas/glob#Compile" "github.com/gobwas/glob"}} -
- - -
- - - {{if ne .HookType "matrix"}}{{/* Matrix doesn't make the authorization optional but it is implied by the help string, should be changed.*/}} - {{ctx.Locale.Tr "repo.settings.authorization_header_desc" (HTMLFormat "%s, %s" "Bearer token123456" "Basic YWxhZGRpbjpvcGVuc2VzYW1l")}} - {{end}} -
- -
- -
-
- - - {{ctx.Locale.Tr "repo.settings.active_helper"}} -
-
{{if $isNew}} {{else}} - {{ctx.Locale.Tr "repo.settings.delete_webhook"}} + {{ctx.Locale.Tr "repo.settings.delete_webhook"}} {{end}}
- -{{template "repo/settings/webhook/delete_modal" .}} diff --git a/templates/repo/settings/webhook/slack.tmpl b/templates/repo/settings/webhook/slack.tmpl index e7cae92d4b..519d6afa1a 100644 --- a/templates/repo/settings/webhook/slack.tmpl +++ b/templates/repo/settings/webhook/slack.tmpl @@ -23,6 +23,7 @@ - {{template "repo/settings/webhook/settings" .}} + {{/* FIXME: support authorization header or not? */}} + {{template "repo/settings/webhook/settings" dict "BaseLink" .BaseLink "Webhook" .Webhook "UseAuthorizationHeader" "optional"}} {{end}} diff --git a/templates/repo/settings/webhook/telegram.tmpl b/templates/repo/settings/webhook/telegram.tmpl index f92c2be0db..5ab89b72cc 100644 --- a/templates/repo/settings/webhook/telegram.tmpl +++ b/templates/repo/settings/webhook/telegram.tmpl @@ -14,6 +14,7 @@ - {{template "repo/settings/webhook/settings" .}} + {{/* FIXME: support authorization header or not? */}} + {{template "repo/settings/webhook/settings" dict "BaseLink" .BaseLink "Webhook" .Webhook "UseAuthorizationHeader" "optional"}} {{end}} diff --git a/templates/repo/settings/webhook/wechatwork.tmpl b/templates/repo/settings/webhook/wechatwork.tmpl index 78a1617123..cbc29b4610 100644 --- a/templates/repo/settings/webhook/wechatwork.tmpl +++ b/templates/repo/settings/webhook/wechatwork.tmpl @@ -6,6 +6,7 @@ - {{template "repo/settings/webhook/settings" .}} + {{/* FIXME: support authorization header or not? */}} + {{template "repo/settings/webhook/settings" dict "BaseLink" .BaseLink "Webhook" .Webhook "UseAuthorizationHeader" "optional"}} {{end}} diff --git a/templates/user/settings/hooks.tmpl b/templates/user/settings/hooks.tmpl index 477c333220..e2d18001f2 100644 --- a/templates/user/settings/hooks.tmpl +++ b/templates/user/settings/hooks.tmpl @@ -1,5 +1,5 @@ {{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings webhooks")}}
- {{template "repo/settings/webhook/list" .}} + {{template "repo/settings/webhook/base_list" .}}
{{template "user/settings/layout_footer" .}} From 9dafcc5c9e779fba1957400a76041ed091660710 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 6 Jul 2025 23:37:26 +0800 Subject: [PATCH 27/34] Improve project & label color picker and image scroll (#34971) Fix #34609 Fix #34967 --- templates/projects/view.tmpl | 2 +- templates/repo/issue/label_precolors.tmpl | 43 +++++++++++-------- .../repo/issue/labels/label_edit_modal.tmpl | 2 +- web_src/css/base.css | 13 ------ web_src/css/features/colorpicker.css | 32 +++++++++++--- web_src/css/features/projects.css | 3 +- web_src/js/features/colorpicker.ts | 36 ++++++++++------ web_src/js/features/comp/LabelEdit.ts | 2 +- 8 files changed, 76 insertions(+), 57 deletions(-) diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index 6aa776da02..692808a32d 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -134,7 +134,7 @@
-
+
{{template "repo/issue/label_precolors"}}
diff --git a/templates/repo/issue/label_precolors.tmpl b/templates/repo/issue/label_precolors.tmpl index 80007662c0..7be3f40350 100644 --- a/templates/repo/issue/label_precolors.tmpl +++ b/templates/repo/issue/label_precolors.tmpl @@ -1,22 +1,27 @@
-
- - - - - - - - -
-
- - - - - - - - + +
+
+ + + + + + + + +
+
+ + + + + + + + +
diff --git a/templates/repo/issue/labels/label_edit_modal.tmpl b/templates/repo/issue/labels/label_edit_modal.tmpl index 6837d66dce..ec57de2f3f 100644 --- a/templates/repo/issue/labels/label_edit_modal.tmpl +++ b/templates/repo/issue/labels/label_edit_modal.tmpl @@ -49,7 +49,7 @@
-
+
{{template "repo/issue/label_precolors"}} diff --git a/web_src/css/base.css b/web_src/css/base.css index 529ddd5386..2b7a47edf1 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -1031,19 +1031,6 @@ table th[data-sortt-desc] .svg { min-height: 0; } -.precolors { - display: flex; - flex-direction: column; - justify-content: center; - margin-left: 1em; -} - -.precolors .color { - display: inline-block; - width: 15px; - height: 15px; -} - .ui.dropdown:not(.button) { line-height: var(--line-height-default); /* the dropdown doesn't have default line-height, use this to make the dropdown icon align with plain dropdown */ } diff --git a/web_src/css/features/colorpicker.css b/web_src/css/features/colorpicker.css index b7436783df..4c517e6348 100644 --- a/web_src/css/features/colorpicker.css +++ b/web_src/css/features/colorpicker.css @@ -1,15 +1,13 @@ -.js-color-picker-input { +.color-picker-combo { display: flex; - position: relative; + position: relative; /* to position the preview square */ } -.js-color-picker-input input { - padding-top: 8px !important; - padding-bottom: 8px !important; +.color-picker-combo input { padding-left: 32px !important; } -.js-color-picker-input .preview-square { +.color-picker-combo .preview-square { position: absolute; aspect-ratio: 1; height: 16px; @@ -22,7 +20,7 @@ background-size: 8px 8px; } -.js-color-picker-input .preview-square::after { +.color-picker-combo .preview-square::after { content: ""; position: absolute; width: 100%; @@ -31,6 +29,26 @@ background-color: currentcolor; } +.color-picker-combo .precolors { + display: flex; + margin-left: 1em; + align-items: center; + gap: 0.125em; +} + +.color-picker-combo .precolors .generate-random-color { + padding: 0; + width: 30px; + height: 30px; + min-height: 0; +} + +.color-picker-combo .precolors .color { + display: inline-block; + width: 15px; + height: 15px; +} + hex-color-picker { width: 180px; height: 120px; diff --git a/web_src/css/features/projects.css b/web_src/css/features/projects.css index 7fd5150970..25cb530f85 100644 --- a/web_src/css/features/projects.css +++ b/web_src/css/features/projects.css @@ -71,7 +71,7 @@ .card-attachment-images { display: inline-block; white-space: nowrap; - overflow: scroll; + overflow: auto; cursor: default; scroll-snap-type: x mandatory; text-align: center; @@ -85,6 +85,7 @@ scroll-snap-align: center; margin-right: 2px; aspect-ratio: 1; + object-fit: contain; } .card-attachment-images img:only-child { diff --git a/web_src/js/features/colorpicker.ts b/web_src/js/features/colorpicker.ts index b99e2f8c45..66d1fcb72a 100644 --- a/web_src/js/features/colorpicker.ts +++ b/web_src/js/features/colorpicker.ts @@ -1,18 +1,19 @@ import {createTippy} from '../modules/tippy.ts'; import type {DOMEvent} from '../utils/dom.ts'; +import {registerGlobalInitFunc} from '../modules/observer.ts'; export async function initColorPickers() { - const els = document.querySelectorAll('.js-color-picker-input'); - if (!els.length) return; - - await Promise.all([ - import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'), - import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'), - ]); - - for (const el of els) { + let imported = false; + registerGlobalInitFunc('initColorPicker', async (el) => { + if (!imported) { + await Promise.all([ + import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'), + import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'), + ]); + imported = true; + } initPicker(el); - } + }); } function updateSquare(el: HTMLElement, newValue: string): void { @@ -55,13 +56,20 @@ function initPicker(el: HTMLElement): void { }, }); - // init precolors + // init random color & precolors + const setSelectedColor = (color: string) => { + input.value = color; + input.dispatchEvent(new Event('input', {bubbles: true})); + updateSquare(square, color); + }; + el.querySelector('.generate-random-color').addEventListener('click', () => { + const newValue = `#${Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')}`; + setSelectedColor(newValue); + }); for (const colorEl of el.querySelectorAll('.precolors .color')) { colorEl.addEventListener('click', (e: DOMEvent) => { const newValue = e.target.getAttribute('data-color-hex'); - input.value = newValue; - input.dispatchEvent(new Event('input', {bubbles: true})); - updateSquare(square, newValue); + setSelectedColor(newValue); }); } } diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts index 423440129c..3e27eac1c5 100644 --- a/web_src/js/features/comp/LabelEdit.ts +++ b/web_src/js/features/comp/LabelEdit.ts @@ -24,7 +24,7 @@ export function initCompLabelEdit(pageSelector: string) { const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field'); const elIsArchivedInput = elModal.querySelector('.label-is-archived-input'); const elDescInput = elModal.querySelector('.label-desc-input'); - const elColorInput = elModal.querySelector('.js-color-picker-input input'); + const elColorInput = elModal.querySelector('.color-picker-combo input'); const syncModalUi = () => { const hasScope = nameHasScope(elNameInput.value); From ba943fb77322d7d575b0f06cae2b2f66f5b5d65d Mon Sep 17 00:00:00 2001 From: MrMars98 Date: Sun, 6 Jul 2025 18:27:26 +0200 Subject: [PATCH 28/34] Fixed minor typos in two files #HSFDPMUW (#34944) Fixed minor typos in CODE_OF_CONDUCT.md and README.md I use the hashtag for a project at my university --------- Signed-off-by: MrMars98 --- CODE_OF_CONDUCT.md | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 979831eb9b..6a7126388e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -30,7 +30,7 @@ These are the values to which people in the Gitea community should aspire. - **Be constructive.** - Avoid derailing: stay on topic; if you want to talk about something else, start a new conversation. - Avoid unconstructive criticism: don't merely decry the current state of affairs; offer—or at least solicit—suggestions as to how things may be improved. - - Avoid snarking (pithy, unproductive, sniping comments) + - Avoid snarking (pithy, unproductive, sniping comments). - Avoid discussing potentially offensive or sensitive issues; this all too often leads to unnecessary conflict. - Avoid microaggressions (brief and commonplace verbal, behavioral and environmental indignities that communicate hostile, derogatory or negative slights and insults to a person or group). - **Be responsible.** @@ -42,7 +42,7 @@ People are complicated. You should expect to be misunderstood and to misundersta ### Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ### Our Standards diff --git a/README.md b/README.md index 017ca629d0..aa2c6010fa 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,9 @@ Expected workflow is: Fork -> Patch -> Push -> Pull Request [![Crowdin](https://badges.crowdin.net/gitea/localized.svg)](https://translate.gitea.com) -Translations are done through [Crowdin](https://translate.gitea.com). If you want to translate to a new language ask one of the managers in the Crowdin project to add a new language there. +Translations are done through [Crowdin](https://translate.gitea.com). If you want to translate to a new language, ask one of the managers in the Crowdin project to add a new language there. -You can also just create an issue for adding a language or ask on discord on the #translation channel. If you need context or find some translation issues, you can leave a comment on the string or ask on Discord. For general translation questions there is a section in the docs. Currently a bit empty but we hope to fill it as questions pop up. +You can also just create an issue for adding a language or ask on Discord on the #translation channel. If you need context or find some translation issues, you can leave a comment on the string or ask on Discord. For general translation questions there is a section in the docs. Currently a bit empty, but we hope to fill it as questions pop up. Get more information from [documentation](https://docs.gitea.com/contributing/localization). From 95a935aca05af3ccf64ecc2a6c19282dd310702f Mon Sep 17 00:00:00 2001 From: silverwind Date: Sun, 6 Jul 2025 18:53:34 +0200 Subject: [PATCH 29/34] Enable gocritic `equalFold` and fix issues (#34952) Continuation of https://github.com/go-gitea/gitea/pull/34678. --------- Signed-off-by: silverwind --- .golangci.yml | 2 ++ models/repo/language_stats.go | 9 ++++----- modules/markup/mdstripper/mdstripper.go | 3 +-- modules/packages/pub/metadata.go | 2 +- modules/setting/actions.go | 4 ++-- modules/util/slice.go | 3 +-- modules/util/time_str.go | 2 +- routers/api/v1/api.go | 2 +- routers/api/v1/repo/collaborators.go | 2 +- routers/api/v1/repo/repo.go | 2 +- routers/web/repo/setting/setting.go | 2 +- services/auth/source/ldap/source_search.go | 2 +- services/context/org.go | 2 +- services/context/repo.go | 2 +- services/context/user.go | 2 +- services/repository/files/file.go | 2 +- 16 files changed, 21 insertions(+), 22 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 70efd288ff..2ad39fbae2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -50,6 +50,8 @@ linters: require-explanation: true require-specific: true gocritic: + enabled-checks: + - equalFold disabled-checks: - ifElseChain - singleCaseSwitch # Every time this occurred in the code, there was no other way. diff --git a/models/repo/language_stats.go b/models/repo/language_stats.go index 0bc0f1fb40..7db8cd4dd2 100644 --- a/models/repo/language_stats.go +++ b/models/repo/language_stats.go @@ -157,18 +157,17 @@ func UpdateLanguageStats(ctx context.Context, repo *Repository, commitID string, for lang, size := range stats { if size > s { s = size - topLang = strings.ToLower(lang) + topLang = lang } } for lang, size := range stats { upd := false - llang := strings.ToLower(lang) for _, s := range oldstats { // Update already existing language - if strings.ToLower(s.Language) == llang { + if strings.EqualFold(s.Language, lang) { s.CommitID = commitID - s.IsPrimary = llang == topLang + s.IsPrimary = lang == topLang s.Size = size if _, err := sess.ID(s.ID).Cols("`commit_id`", "`size`", "`is_primary`").Update(s); err != nil { return err @@ -182,7 +181,7 @@ func UpdateLanguageStats(ctx context.Context, repo *Repository, commitID string, if err := db.Insert(ctx, &LanguageStat{ RepoID: repo.ID, CommitID: commitID, - IsPrimary: llang == topLang, + IsPrimary: lang == topLang, Language: lang, Size: size, }); err != nil { diff --git a/modules/markup/mdstripper/mdstripper.go b/modules/markup/mdstripper/mdstripper.go index 6e392444b4..5a6504416a 100644 --- a/modules/markup/mdstripper/mdstripper.go +++ b/modules/markup/mdstripper/mdstripper.go @@ -91,8 +91,7 @@ func (r *stripRenderer) processAutoLink(w io.Writer, link []byte) { } // Note: we're not attempting to match the URL scheme (http/https) - host := strings.ToLower(u.Host) - if host != "" && host != strings.ToLower(r.localhost.Host) { + if u.Host != "" && !strings.EqualFold(u.Host, r.localhost.Host) { // Process out of band r.links = append(r.links, linkStr) return diff --git a/modules/packages/pub/metadata.go b/modules/packages/pub/metadata.go index afb464e462..9b00472eb2 100644 --- a/modules/packages/pub/metadata.go +++ b/modules/packages/pub/metadata.go @@ -88,7 +88,7 @@ func ParsePackage(r io.Reader) (*Package, error) { if err != nil { return nil, err } - } else if strings.ToLower(hd.Name) == "readme.md" { + } else if strings.EqualFold(hd.Name, "readme.md") { data, err := io.ReadAll(tr) if err != nil { return nil, err diff --git a/modules/setting/actions.go b/modules/setting/actions.go index 913872eaf2..8bace1f750 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -62,11 +62,11 @@ func (c logCompression) IsValid() bool { } func (c logCompression) IsNone() bool { - return strings.ToLower(string(c)) == "none" + return string(c) == "none" } func (c logCompression) IsZstd() bool { - return c == "" || strings.ToLower(string(c)) == "zstd" + return c == "" || string(c) == "zstd" } func loadActionsFrom(rootCfg ConfigProvider) error { diff --git a/modules/util/slice.go b/modules/util/slice.go index da6886491e..aaa729c1c9 100644 --- a/modules/util/slice.go +++ b/modules/util/slice.go @@ -12,8 +12,7 @@ import ( // SliceContainsString sequential searches if string exists in slice. func SliceContainsString(slice []string, target string, insensitive ...bool) bool { if len(insensitive) != 0 && insensitive[0] { - target = strings.ToLower(target) - return slices.ContainsFunc(slice, func(t string) bool { return strings.ToLower(t) == target }) + return slices.ContainsFunc(slice, func(t string) bool { return strings.EqualFold(t, target) }) } return slices.Contains(slice, target) diff --git a/modules/util/time_str.go b/modules/util/time_str.go index 0fccfe82cc..81b132c3db 100644 --- a/modules/util/time_str.go +++ b/modules/util/time_str.go @@ -59,7 +59,7 @@ func TimeEstimateParse(timeStr string) (int64, error) { unit := timeStr[match[4]:match[5]] found := false for _, u := range timeStrGlobalVars().units { - if strings.ToLower(unit) == u.name { + if strings.EqualFold(unit, u.name) { total += amount * u.num found = true break diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 4a4bf12657..f412e8a06c 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -145,7 +145,7 @@ func repoAssignment() func(ctx *context.APIContext) { ) // Check if the user is the same as the repository owner. - if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(userName) { + if ctx.IsSigned && strings.EqualFold(ctx.Doer.LowerName, userName) { owner = ctx.Doer } else { owner, err = user_model.GetUserByName(ctx, userName) diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go index c2c10cc695..eed9c19fe1 100644 --- a/routers/api/v1/repo/collaborators.go +++ b/routers/api/v1/repo/collaborators.go @@ -276,7 +276,7 @@ func GetRepoPermissions(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" collaboratorUsername := ctx.PathParam("collaborator") - if !ctx.Doer.IsAdmin && ctx.Doer.LowerName != strings.ToLower(collaboratorUsername) && !ctx.IsUserRepoAdmin() { + if !ctx.Doer.IsAdmin && !strings.EqualFold(ctx.Doer.LowerName, collaboratorUsername) && !ctx.IsUserRepoAdmin() { ctx.APIError(http.StatusForbidden, "Only admins can query all permissions, repo admins can query all repo permissions, collaborators can query only their own") return } diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 8acc912796..292b267c01 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -669,7 +669,7 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err newRepoName = *opts.Name } // Check if repository name has been changed and not just a case change - if repo.LowerName != strings.ToLower(newRepoName) { + if !strings.EqualFold(repo.LowerName, newRepoName) { if err := repo_service.ChangeRepositoryName(ctx, ctx.Doer, repo, newRepoName); err != nil { switch { case repo_model.IsErrRepoAlreadyExist(err): diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 6e16ead183..0865d9d7c0 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -165,7 +165,7 @@ func handleSettingsPostUpdate(ctx *context.Context) { newRepoName := form.RepoName // Check if repository name has been changed. - if repo.LowerName != strings.ToLower(newRepoName) { + if !strings.EqualFold(repo.LowerName, newRepoName) { // Close the GitRepo if open if ctx.Repo.GitRepo != nil { ctx.Repo.GitRepo.Close() diff --git a/services/auth/source/ldap/source_search.go b/services/auth/source/ldap/source_search.go index e6bce04a83..f6c032492f 100644 --- a/services/auth/source/ldap/source_search.go +++ b/services/auth/source/ldap/source_search.go @@ -241,7 +241,7 @@ func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGr } func (source *Source) getUserAttributeListedInGroup(entry *ldap.Entry) string { - if strings.ToLower(source.UserUID) == "dn" { + if strings.EqualFold(source.UserUID, "dn") { return entry.DN } diff --git a/services/context/org.go b/services/context/org.go index c8b6ed09b7..1cd8923178 100644 --- a/services/context/org.go +++ b/services/context/org.go @@ -208,7 +208,7 @@ func OrgAssignment(opts OrgAssignmentOptions) func(ctx *Context) { if len(teamName) > 0 { teamExists := false for _, team := range ctx.Org.Teams { - if team.LowerName == strings.ToLower(teamName) { + if strings.EqualFold(team.LowerName, teamName) { teamExists = true ctx.Org.Team = team ctx.Org.IsTeamMember = true diff --git a/services/context/repo.go b/services/context/repo.go index 572211712b..afc6de9b16 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -429,7 +429,7 @@ func RepoAssignment(ctx *Context) { } // Check if the user is the same as the repository owner - if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(userName) { + if ctx.IsSigned && strings.EqualFold(ctx.Doer.LowerName, userName) { ctx.Repo.Owner = ctx.Doer } else { ctx.Repo.Owner, err = user_model.GetUserByName(ctx, userName) diff --git a/services/context/user.go b/services/context/user.go index c09ded8339..f1a3035ee9 100644 --- a/services/context/user.go +++ b/services/context/user.go @@ -61,7 +61,7 @@ func UserAssignmentAPI() func(ctx *APIContext) { func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, any)) (contextUser *user_model.User) { username := ctx.PathParam("username") - if doer != nil && doer.LowerName == strings.ToLower(username) { + if doer != nil && strings.EqualFold(doer.LowerName, username) { contextUser = doer } else { var err error diff --git a/services/repository/files/file.go b/services/repository/files/file.go index 13d171d139..f48e32b427 100644 --- a/services/repository/files/file.go +++ b/services/repository/files/file.go @@ -144,7 +144,7 @@ func CleanGitTreePath(name string) string { name = util.PathJoinRel(name) // Git disallows any filenames to have a .git directory in them. for part := range strings.SplitSeq(name, "/") { - if strings.ToLower(part) == ".git" { + if strings.EqualFold(part, ".git") { return "" } } From 6b42ea1e54531650e99503aa39158ec8593c0c98 Mon Sep 17 00:00:00 2001 From: NorthRealm <155140859+NorthRealm@users.noreply.github.com> Date: Mon, 7 Jul 2025 01:47:02 +0800 Subject: [PATCH 30/34] Rerun job only when run is done (#34970) For consistency, limit rerunning Job(s) to only when Run is in Done status. --- routers/web/repo/actions/view.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 7e1b923fa4..52b2e9995e 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -249,7 +249,7 @@ func ViewPost(ctx *context_module.Context) { ID: v.ID, Name: v.Name, Status: v.Status.String(), - CanRerun: v.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions), + CanRerun: resp.State.Run.CanRerun, Duration: v.Duration().String(), }) } @@ -445,7 +445,7 @@ func Rerun(ctx *context_module.Context) { return } } - ctx.JSON(http.StatusOK, struct{}{}) + ctx.JSONOK() return } @@ -460,12 +460,12 @@ func Rerun(ctx *context_module.Context) { } } - ctx.JSON(http.StatusOK, struct{}{}) + ctx.JSONOK() } func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { status := job.Status - if !status.IsDone() { + if !status.IsDone() || !job.Run.Status.IsDone() { return nil } From ddfa2e4a3ef18cdef0f66d69af2c581ffe095f9b Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Mon, 7 Jul 2025 00:41:20 +0000 Subject: [PATCH 31/34] [skip ci] Updated translations via Crowdin --- options/locale/locale_ga-IE.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini index 99c8b56f60..49ce14aded 100644 --- a/options/locale/locale_ga-IE.ini +++ b/options/locale/locale_ga-IE.ini @@ -2334,6 +2334,8 @@ settings.hooks_desc=Déanann Crúcaí Gréasán iarratais HTTP POST go huathoibr settings.webhook_deletion=Bain Crúca Gréasán settings.webhook_deletion_desc=Scriostar a shocruithe agus a stair seachadta a bhaineann le Crúca Gréasán a bhaint. Lean ar aghaidh? settings.webhook_deletion_success=Tá an Crúca Gréasán bainte amach. +settings.webhook.test_delivery=Imeacht Brúigh Tástála +settings.webhook.test_delivery_desc=Déan tástáil ar an webhook seo le teagmhas brú bréige. settings.webhook.test_delivery_desc_disabled=Chun an Crúca Gréasán seo a thástáil le himeacht bhréige, gníomhachtaigh é. settings.webhook.request=Iarratas settings.webhook.response=Freagra From f88800d54505f30dbae089d446e292f176222d99 Mon Sep 17 00:00:00 2001 From: Scion Date: Mon, 7 Jul 2025 03:43:58 -0700 Subject: [PATCH 32/34] Improve NuGet API Parity (#21291) (#34940) Fixes #21291, allowing icons and other missing attributes to appear for NuGet packages from inside Visual Studio like they do with GitHub Nuget packages. Adds additional NuGet package information, particularly `IconURL`, to bring the Gitea NuGet API more in-line with GitHub's NuGet API. ref: https://learn.microsoft.com/en-us/nuget/api/search-query-service-resource --- models/packages/package_version.go | 8 ++ modules/packages/nuget/metadata.go | 3 + routers/api/packages/nuget/api_v3.go | 144 +++++++++++++------ tests/integration/api_packages_nuget_test.go | 88 +++++++++--- 4 files changed, 184 insertions(+), 59 deletions(-) diff --git a/models/packages/package_version.go b/models/packages/package_version.go index 5672e0efbf..0a478c0323 100644 --- a/models/packages/package_version.go +++ b/models/packages/package_version.go @@ -37,6 +37,14 @@ type PackageVersion struct { DownloadCount int64 `xorm:"NOT NULL DEFAULT 0"` } +// IsPrerelease checks if the version is a prerelease version according to semantic versioning +func (pv *PackageVersion) IsPrerelease() bool { + if pv == nil || pv.Version == "" { + return false + } + return strings.Contains(pv.Version, "-") +} + // GetOrInsertVersion inserts a version. If the same version exist already ErrDuplicatePackageVersion is returned func GetOrInsertVersion(ctx context.Context, pv *PackageVersion) (*PackageVersion, error) { e := db.GetEngine(ctx) diff --git a/modules/packages/nuget/metadata.go b/modules/packages/nuget/metadata.go index a122590bf1..513b4dd2b9 100644 --- a/modules/packages/nuget/metadata.go +++ b/modules/packages/nuget/metadata.go @@ -71,6 +71,7 @@ type Metadata struct { ReleaseNotes string `json:"release_notes,omitempty"` RepositoryURL string `json:"repository_url,omitempty"` RequireLicenseAcceptance bool `json:"require_license_acceptance"` + Summary string `json:"summary,omitempty"` Tags string `json:"tags,omitempty"` Title string `json:"title,omitempty"` @@ -105,6 +106,7 @@ type nuspecPackage struct { Readme string `xml:"readme"` ReleaseNotes string `xml:"releaseNotes"` RequireLicenseAcceptance bool `xml:"requireLicenseAcceptance"` + Summary string `xml:"summary"` Tags string `xml:"tags"` Title string `xml:"title"` @@ -204,6 +206,7 @@ func ParseNuspecMetaData(archive *zip.Reader, r io.Reader) (*Package, error) { ReleaseNotes: p.Metadata.ReleaseNotes, RepositoryURL: p.Metadata.Repository.URL, RequireLicenseAcceptance: p.Metadata.RequireLicenseAcceptance, + Summary: p.Metadata.Summary, Tags: p.Metadata.Tags, Title: p.Metadata.Title, diff --git a/routers/api/packages/nuget/api_v3.go b/routers/api/packages/nuget/api_v3.go index 2fe25dc0f8..3262f2d9af 100644 --- a/routers/api/packages/nuget/api_v3.go +++ b/routers/api/packages/nuget/api_v3.go @@ -53,15 +53,23 @@ type RegistrationIndexPageItem struct { // https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#catalog-entry type CatalogEntry struct { CatalogLeafURL string `json:"@id"` - PackageContentURL string `json:"packageContent"` - ID string `json:"id"` - Version string `json:"version"` - Description string `json:"description"` - ReleaseNotes string `json:"releaseNotes"` Authors string `json:"authors"` - RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"` - ProjectURL string `json:"projectURL"` + Copyright string `json:"copyright"` DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"` + Description string `json:"description"` + IconURL string `json:"iconUrl"` + ID string `json:"id"` + IsPrerelease bool `json:"isPrerelease"` + Language string `json:"language"` + LicenseURL string `json:"licenseUrl"` + PackageContentURL string `json:"packageContent"` + ProjectURL string `json:"projectUrl"` + RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"` + Summary string `json:"summary"` + Tags string `json:"tags"` + Version string `json:"version"` + ReleaseNotes string `json:"releaseNotes"` + Published time.Time `json:"published"` } // https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#package-dependency-group @@ -109,15 +117,24 @@ func createRegistrationIndexPageItem(l *linkBuilder, pd *packages_model.PackageD RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), CatalogEntry: &CatalogEntry{ - CatalogLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), - PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), - ID: pd.Package.Name, - Version: pd.Version.Version, - Description: metadata.Description, - ReleaseNotes: metadata.ReleaseNotes, - Authors: metadata.Authors, - ProjectURL: metadata.ProjectURL, - DependencyGroups: createDependencyGroups(pd), + CatalogLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), + Authors: metadata.Authors, + Copyright: metadata.Copyright, + DependencyGroups: createDependencyGroups(pd), + Description: metadata.Description, + IconURL: metadata.IconURL, + ID: pd.Package.Name, + IsPrerelease: pd.Version.IsPrerelease(), + Language: metadata.Language, + LicenseURL: metadata.LicenseURL, + PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), + ProjectURL: metadata.ProjectURL, + RequireLicenseAcceptance: metadata.RequireLicenseAcceptance, + Summary: metadata.Summary, + Tags: metadata.Tags, + Version: pd.Version.Version, + ReleaseNotes: metadata.ReleaseNotes, + Published: pd.Version.CreatedUnix.AsLocalTime(), }, } } @@ -145,22 +162,42 @@ func createDependencyGroups(pd *packages_model.PackageDescriptor) []*PackageDepe // https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource#registration-leaf type RegistrationLeafResponse struct { - RegistrationLeafURL string `json:"@id"` - Type []string `json:"@type"` - Listed bool `json:"listed"` - PackageContentURL string `json:"packageContent"` - Published time.Time `json:"published"` - RegistrationIndexURL string `json:"registration"` + RegistrationLeafURL string `json:"@id"` + Type []string `json:"@type"` + PackageContentURL string `json:"packageContent"` + RegistrationIndexURL string `json:"registration"` + CatalogEntry CatalogEntry `json:"catalogEntry"` } func createRegistrationLeafResponse(l *linkBuilder, pd *packages_model.PackageDescriptor) *RegistrationLeafResponse { + registrationLeafURL := l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version) + packageDownloadURL := l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version) + metadata := pd.Metadata.(*nuget_module.Metadata) return &RegistrationLeafResponse{ - Type: []string{"Package", "http://schema.nuget.org/catalog#Permalink"}, - Listed: true, - Published: pd.Version.CreatedUnix.AsLocalTime(), - RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), - PackageContentURL: l.GetPackageDownloadURL(pd.Package.Name, pd.Version.Version), + RegistrationLeafURL: registrationLeafURL, RegistrationIndexURL: l.GetRegistrationIndexURL(pd.Package.Name), + PackageContentURL: packageDownloadURL, + Type: []string{"Package", "http://schema.nuget.org/catalog#Permalink"}, + CatalogEntry: CatalogEntry{ + CatalogLeafURL: registrationLeafURL, + Authors: metadata.Authors, + Copyright: metadata.Copyright, + DependencyGroups: createDependencyGroups(pd), + Description: metadata.Description, + IconURL: metadata.IconURL, + ID: pd.Package.Name, + IsPrerelease: pd.Version.IsPrerelease(), + Language: metadata.Language, + LicenseURL: metadata.LicenseURL, + PackageContentURL: packageDownloadURL, + ProjectURL: metadata.ProjectURL, + RequireLicenseAcceptance: metadata.RequireLicenseAcceptance, + Summary: metadata.Summary, + Tags: metadata.Tags, + Version: pd.Version.Version, + ReleaseNotes: metadata.ReleaseNotes, + Published: pd.Version.CreatedUnix.AsLocalTime(), + }, } } @@ -188,13 +225,24 @@ type SearchResultResponse struct { // https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result type SearchResult struct { - ID string `json:"id"` - Version string `json:"version"` - Versions []*SearchResultVersion `json:"versions"` - Description string `json:"description"` - Authors string `json:"authors"` - ProjectURL string `json:"projectURL"` - RegistrationIndexURL string `json:"registration"` + Authors string `json:"authors"` + Copyright string `json:"copyright"` + DependencyGroups []*PackageDependencyGroup `json:"dependencyGroups"` + Description string `json:"description"` + IconURL string `json:"iconUrl"` + ID string `json:"id"` + IsPrerelease bool `json:"isPrerelease"` + Language string `json:"language"` + LicenseURL string `json:"licenseUrl"` + ProjectURL string `json:"projectUrl"` + RequireLicenseAcceptance bool `json:"requireLicenseAcceptance"` + Summary string `json:"summary"` + Tags string `json:"tags"` + Title string `json:"title"` + TotalDownloads int64 `json:"totalDownloads"` + Version string `json:"version"` + Versions []*SearchResultVersion `json:"versions"` + RegistrationIndexURL string `json:"registration"` } // https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource#search-result @@ -230,11 +278,12 @@ func createSearchResultResponse(l *linkBuilder, totalHits int64, pds []*packages func createSearchResult(l *linkBuilder, pds []*packages_model.PackageDescriptor) *SearchResult { latest := pds[0] versions := make([]*SearchResultVersion, 0, len(pds)) + totalDownloads := int64(0) for _, pd := range pds { if latest.SemVer.LessThan(pd.SemVer) { latest = pd } - + totalDownloads += pd.Version.DownloadCount versions = append(versions, &SearchResultVersion{ RegistrationLeafURL: l.GetRegistrationLeafURL(pd.Package.Name, pd.Version.Version), Version: pd.Version.Version, @@ -244,12 +293,23 @@ func createSearchResult(l *linkBuilder, pds []*packages_model.PackageDescriptor) metadata := latest.Metadata.(*nuget_module.Metadata) return &SearchResult{ - ID: latest.Package.Name, - Version: latest.Version.Version, - Versions: versions, - Description: metadata.Description, - Authors: metadata.Authors, - ProjectURL: metadata.ProjectURL, - RegistrationIndexURL: l.GetRegistrationIndexURL(latest.Package.Name), + Authors: metadata.Authors, + Copyright: metadata.Copyright, + Description: metadata.Description, + DependencyGroups: createDependencyGroups(latest), + IconURL: metadata.IconURL, + ID: latest.Package.Name, + IsPrerelease: latest.Version.IsPrerelease(), + Language: metadata.Language, + LicenseURL: metadata.LicenseURL, + ProjectURL: metadata.ProjectURL, + RequireLicenseAcceptance: metadata.RequireLicenseAcceptance, + Summary: metadata.Summary, + Tags: metadata.Tags, + Title: metadata.Title, + TotalDownloads: totalDownloads, + Version: latest.Version.Version, + Versions: versions, + RegistrationIndexURL: l.GetRegistrationIndexURL(latest.Package.Name), } } diff --git a/tests/integration/api_packages_nuget_test.go b/tests/integration/api_packages_nuget_test.go index 65b1b9845a..529b540062 100644 --- a/tests/integration/api_packages_nuget_test.go +++ b/tests/integration/api_packages_nuget_test.go @@ -14,6 +14,7 @@ import ( "net/http/httptest" neturl "net/url" "strconv" + "strings" "testing" "time" @@ -100,6 +101,7 @@ func TestPackageNuGet(t *testing.T) { packageVersion := "1.0.3" packageAuthors := "KN4CK3R" packageDescription := "Gitea Test Package" + isPrerelease := strings.Contains(packageVersion, "-") symbolFilename := "test.pdb" symbolID := "d910bb6948bd4c6cb40155bcf52c3c94" @@ -112,11 +114,17 @@ func TestPackageNuGet(t *testing.T) { packageOwners := "Package Owners" packageProjectURL := "https://gitea.io" packageReleaseNotes := "Package Release Notes" + summary := "This is a test package." packageTags := "tag_1 tag_2 tag_3" packageTitle := "Package Title" packageDevelopmentDependency := true packageRequireLicenseAcceptance := true + dependencyCount := 1 + dependencyTargetFramework := ".NETStandard2.0" + dependencyID := "Microsoft.CSharp" + dependencyVersion := "4.5.0" + createNuspec := func(id, version string) string { return ` @@ -133,12 +141,13 @@ func TestPackageNuGet(t *testing.T) { ` + packageProjectURL + ` ` + packageReleaseNotes + ` true + ` + summary + ` ` + packageTags + ` ` + packageTitle + ` ` + version + ` - - + + @@ -428,7 +437,7 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) assert.NoError(t, err) - assert.Equal(t, int64(610), pb.Size) + assert.Equal(t, int64(633), pb.Size) case fmt.Sprintf("%s.%s.snupkg", packageName, packageVersion): assert.False(t, pf.IsLead) @@ -440,7 +449,7 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) assert.NoError(t, err) - assert.Equal(t, int64(996), pb.Size) + assert.Equal(t, int64(1043), pb.Size) case symbolFilename: assert.False(t, pf.IsLead) @@ -747,17 +756,39 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) assert.Equal(t, indexURL, result.RegistrationIndexURL) assert.Equal(t, 1, result.Count) assert.Len(t, result.Pages, 1) - assert.Equal(t, indexURL, result.Pages[0].RegistrationPageURL) - assert.Equal(t, packageVersion, result.Pages[0].Lower) - assert.Equal(t, packageVersion, result.Pages[0].Upper) - assert.Equal(t, 1, result.Pages[0].Count) - assert.Len(t, result.Pages[0].Items, 1) - assert.Equal(t, packageName, result.Pages[0].Items[0].CatalogEntry.ID) - assert.Equal(t, packageVersion, result.Pages[0].Items[0].CatalogEntry.Version) - assert.Equal(t, packageAuthors, result.Pages[0].Items[0].CatalogEntry.Authors) - assert.Equal(t, packageDescription, result.Pages[0].Items[0].CatalogEntry.Description) - assert.Equal(t, leafURL, result.Pages[0].Items[0].CatalogEntry.CatalogLeafURL) - assert.Equal(t, contentURL, result.Pages[0].Items[0].CatalogEntry.PackageContentURL) + + page := result.Pages[0] + assert.Equal(t, indexURL, page.RegistrationPageURL) + assert.Equal(t, packageVersion, page.Lower) + assert.Equal(t, packageVersion, page.Upper) + assert.Equal(t, 1, page.Count) + assert.Len(t, page.Items, 1) + + item := page.Items[0] + assert.Equal(t, packageName, item.CatalogEntry.ID) + assert.Equal(t, packageVersion, item.CatalogEntry.Version) + assert.Equal(t, packageAuthors, item.CatalogEntry.Authors) + assert.Equal(t, packageDescription, item.CatalogEntry.Description) + assert.Equal(t, leafURL, item.CatalogEntry.CatalogLeafURL) + assert.Equal(t, contentURL, item.CatalogEntry.PackageContentURL) + assert.Equal(t, packageIconURL, item.CatalogEntry.IconURL) + assert.Equal(t, packageLanguage, item.CatalogEntry.Language) + assert.Equal(t, packageLicenseURL, item.CatalogEntry.LicenseURL) + assert.Equal(t, packageProjectURL, item.CatalogEntry.ProjectURL) + assert.Equal(t, packageReleaseNotes, item.CatalogEntry.ReleaseNotes) + assert.Equal(t, packageRequireLicenseAcceptance, item.CatalogEntry.RequireLicenseAcceptance) + assert.Equal(t, packageTags, item.CatalogEntry.Tags) + assert.Equal(t, summary, item.CatalogEntry.Summary) + assert.Equal(t, isPrerelease, item.CatalogEntry.IsPrerelease) + assert.Len(t, item.CatalogEntry.DependencyGroups, dependencyCount) + + dependencyGroup := item.CatalogEntry.DependencyGroups[0] + assert.Equal(t, dependencyTargetFramework, dependencyGroup.TargetFramework) + assert.Len(t, dependencyGroup.Dependencies, dependencyCount) + + dependency := dependencyGroup.Dependencies[0] + assert.Equal(t, dependencyID, dependency.ID) + assert.Equal(t, dependencyVersion, dependency.Range) }) t.Run("RegistrationLeaf", func(t *testing.T) { @@ -789,7 +820,8 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) assert.Equal(t, packageTags, result.Properties.Tags) assert.Equal(t, packageTitle, result.Properties.Title) - assert.Equal(t, "Microsoft.CSharp:4.5.0:.NETStandard2.0", result.Properties.Dependencies) + packageVersion := strings.Join([]string{dependencyID, dependencyVersion, dependencyTargetFramework}, ":") + assert.Equal(t, packageVersion, result.Properties.Dependencies) }) t.Run("v3", func(t *testing.T) { @@ -803,8 +835,30 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) DecodeJSON(t, resp, &result) assert.Equal(t, leafURL, result.RegistrationLeafURL) - assert.Equal(t, contentURL, result.PackageContentURL) assert.Equal(t, indexURL, result.RegistrationIndexURL) + assert.Equal(t, packageAuthors, result.CatalogEntry.Authors) + assert.Equal(t, packageCopyright, result.CatalogEntry.Copyright) + + dependencyGroup := result.CatalogEntry.DependencyGroups[0] + assert.Equal(t, dependencyTargetFramework, dependencyGroup.TargetFramework) + assert.Len(t, dependencyGroup.Dependencies, dependencyCount) + + dependency := dependencyGroup.Dependencies[0] + assert.Equal(t, dependencyID, dependency.ID) + assert.Equal(t, dependencyVersion, dependency.Range) + + assert.Equal(t, packageDescription, result.CatalogEntry.Description) + assert.Equal(t, packageID, result.CatalogEntry.ID) + assert.Equal(t, packageIconURL, result.CatalogEntry.IconURL) + assert.Equal(t, isPrerelease, result.CatalogEntry.IsPrerelease) + assert.Equal(t, packageLanguage, result.CatalogEntry.Language) + assert.Equal(t, packageLicenseURL, result.CatalogEntry.LicenseURL) + assert.Equal(t, contentURL, result.PackageContentURL) + assert.Equal(t, packageProjectURL, result.CatalogEntry.ProjectURL) + assert.Equal(t, packageRequireLicenseAcceptance, result.CatalogEntry.RequireLicenseAcceptance) + assert.Equal(t, summary, result.CatalogEntry.Summary) + assert.Equal(t, packageTags, result.CatalogEntry.Tags) + assert.Equal(t, packageVersion, result.CatalogEntry.Version) }) }) }) From 08682212ab631572bc680251ceb4daa7bb4e7b9d Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Tue, 8 Jul 2025 00:37:29 +0000 Subject: [PATCH 33/34] [skip ci] Updated translations via Crowdin --- options/locale/locale_ga-IE.ini | 1 + options/locale/locale_pt-PT.ini | 1 + 2 files changed, 2 insertions(+) diff --git a/options/locale/locale_ga-IE.ini b/options/locale/locale_ga-IE.ini index 49ce14aded..965cce0c68 100644 --- a/options/locale/locale_ga-IE.ini +++ b/options/locale/locale_ga-IE.ini @@ -2355,6 +2355,7 @@ settings.payload_url=URL spriocdhírithe settings.http_method=Modh HTTP settings.content_type=Cineál Ábhar POST settings.secret=Rúnda +settings.webhook_secret_desc=Más féidir le freastalaí an webhook rún a úsáid, is féidir leat lámhleabhar an webhook a leanúint agus rún a líonadh isteach anseo. settings.slack_username=Ainm úsáideora settings.slack_icon_url=URL deilbhín settings.slack_color=Dath diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index 2ebea36af9..f10bef0687 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -2353,6 +2353,7 @@ settings.payload_url=URL de destino settings.http_method=Método HTTP settings.content_type=Tipo de conteúdo POST settings.secret=Segredo +settings.webhook_secret_desc=Se o servidor de automatismos web suportar a utilização de segredos, você pode seguir o manual do automatismo web e preencher um segredo aqui. settings.slack_username=Nome de utilizador settings.slack_icon_url=URL do ícone settings.slack_color=Cor From 3763c2ae28ebfa539cefb7ce0830cf5ee6c6f1df Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 8 Jul 2025 16:59:31 +0800 Subject: [PATCH 34/34] Refactor time tracker UI (#34983) Although we decided to "reduce the button amount" on the side bar, not only one user reported that the "time tracker dropdown" is not easy to use. So the best we can do at the moment is: move the buttons to the sidebar again. Fix #34979 --- templates/devtest/fomantic-modal.tmpl | 16 ++-- templates/devtest/gitea-ui.tmpl | 14 ++-- .../issue/sidebar/stopwatch_timetracker.tmpl | 77 ++++++++----------- web_src/css/modules/button.css | 2 +- web_src/js/index-domready.ts | 2 + web_src/js/standalone/devtest.ts | 1 + 6 files changed, 54 insertions(+), 58 deletions(-) diff --git a/templates/devtest/fomantic-modal.tmpl b/templates/devtest/fomantic-modal.tmpl index 838c6893a4..8e769790b2 100644 --- a/templates/devtest/fomantic-modal.tmpl +++ b/templates/devtest/fomantic-modal.tmpl @@ -2,13 +2,15 @@
{{template "base/alert" .}} - -
diff --git a/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl b/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl index ab8600b068..6168b06e17 100644 --- a/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl +++ b/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl @@ -2,35 +2,28 @@ {{if and .CanUseTimetracker (not .Repository.IsArchived)}}
- {{end}} {{if .WorkingUsers}} -
+
{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Hour)}} -
- {{range $user, $trackedtime := .WorkingUsers}} -
- - {{ctx.AvatarUtils.Avatar $user}} - -
- {{template "shared/user/authorlink" $user}} -
- {{$trackedtime|Sec2Hour}} -
-
+
+
+ {{range $user, $trackedtime := .WorkingUsers}} +
+ {{template "shared/user/avatarlink" dict "user" $user}} +
+ {{template "shared/user/authorlink" $user}} +
{{$trackedtime|Sec2Hour}}
- {{end}} -
+
+ {{end}}
{{end}} {{end}} diff --git a/web_src/css/modules/button.css b/web_src/css/modules/button.css index b105bb5de2..8e3309474b 100644 --- a/web_src/css/modules/button.css +++ b/web_src/css/modules/button.css @@ -366,8 +366,8 @@ It needs some tricks to tweak the left/right borders with active state */ .ui.buttons .button { border-right: none; - flex: 1 0 auto; border-radius: 0; + flex-shrink: 0; margin: 0; } .ui.buttons .button:first-child { diff --git a/web_src/js/index-domready.ts b/web_src/js/index-domready.ts index 4d7ab98db0..ca18d1e828 100644 --- a/web_src/js/index-domready.ts +++ b/web_src/js/index-domready.ts @@ -175,3 +175,5 @@ const initDur = performance.now() - initStartTime; if (initDur > 500) { console.error(`slow init functions took ${initDur.toFixed(3)}ms`); } + +document.dispatchEvent(new CustomEvent('gitea:index-ready')); diff --git a/web_src/js/standalone/devtest.ts b/web_src/js/standalone/devtest.ts index e6baf6c9ce..faa38dc467 100644 --- a/web_src/js/standalone/devtest.ts +++ b/web_src/js/standalone/devtest.ts @@ -11,4 +11,5 @@ function initDevtestToast() { } } +// NOTICE: keep in mind that this file is not in "index.js", they do not share the same module system. initDevtestToast();