Compare commits

..

10 Commits

Author SHA1 Message Date
Giteabot 0b5c721971 enhance: allow attribute-less MathML elements and complete the Core allowlist (#39337) (#39340)
Backport #39337 by @nschloe

Follow-up to https://github.com/go-gitea/gitea/pull/38034.

bluemonday only keeps an element without attributes if it was registered
via `AllowNoAttrs`. The MathML rules only used
`AllowAttrs(...).OnElements(...)`, so plain `<mi>x</mi>`, `<mrow>`,
`<msqrt>` and friends were stripped to bare text. Since real-world
MathML is almost entirely attribute-less elements, nearly every formula
collapsed. The existing test missed it because its only case gave every
element an attribute.

Also fills the remaining gaps against MathML Core
(https://www.w3.org/TR/mathml-core/): `rowspan` on `mtd` alongside
`columnspan`, the `maction` element with `actiontype` and `selection`,
and the reserved global attributes `intent` and `arg`.

Tests cover attribute-less round-trips, table cell spans, and `maction`
with `intent`/`arg`.

Co-authored-by: Nico Schlömer <nschloe@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-17 10:45:07 -07:00
Giteabot 2c7a442692 fix: match install page update checker setting with app.ini (#39317) (#39327)
Co-authored-by: afishcalledwander <327599756+afishcalledwander@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-16 19:22:19 +08:00
wxiaoguang e277be9e12 fix: correct stdErr match in isErrBlameNotFoundOrNotEnoughLines (#39309) (#39311) 2026-09-15 07:19:17 +08:00
Zettat123 324be9a731 fix: restore owner name in PR target branch selector (#39262) (#39277) 2026-09-09 21:56:31 -07:00
Giteabot e8e8bbdbb5 fix(api): enforce mustNotBeArchived on repo topics routes (#39260) (#39266)
Co-authored-by: Rathina Devan E M <emrathinadev@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-08 20:15:45 +02:00
wxiaoguang bc11b8ac22 fix: backport various fixes (#39203, #39236, #39239, #39248, #39251) (#39252) 2026-09-06 22:05:11 +08:00
wxiaoguang a127bc1013 fix: action run list page error (#39212) (#39234) 2026-09-04 08:45:39 +00:00
Giteabot c51da382d1 fix(pulls): hide branch deletion when an open PR uses the branch (#39193) (#39218)
Co-authored-by: 开心 <happys2333@outlook.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-04 02:56:58 +02:00
Giteabot e3d9b03f84 fix(git): keep IPv6 brackets in submodule web links (#39186) (#39198)
Co-authored-by: Zhaoqi Xu <lzy00419@outlook.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-01 06:59:58 +00:00
Giteabot f0b4a4142b fix: charset lookup (#39187) (#39197)
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-01 08:35:50 +02:00
32 changed files with 376 additions and 122 deletions
+1 -1
View File
@@ -1027,7 +1027,7 @@ LEVEL = Info
;; If the charsets have equal confidence, tie-breaking will be done by order in this list
;; with charsets earlier in the list chosen in preference to those later.
;; Adding "defaults" will place the unused charsets at that position.
;DETECTED_CHARSETS_ORDER = UTF-8, UTF-16BE, UTF-16LE, UTF-32BE, UTF-32LE, ISO-8859, windows-1252, ISO-8859, windows-1250, ISO-8859, ISO-8859, ISO-8859, windows-1253, ISO-8859, windows-1255, ISO-8859, windows-1251, windows-1256, KOI8-R, ISO-8859, windows-1254, Shift_JIS, GB18030, EUC-JP, EUC-KR, Big5, ISO-2022, ISO-2022, ISO-2022, IBM424_rtl, IBM424_ltr, IBM420_rtl, IBM420_ltr
;DETECTED_CHARSETS_ORDER = UTF-8, UTF-16BE, UTF-16LE, UTF-32BE, UTF-32LE, ISO-8859, windows-1252, ISO-8859, windows-1250, ISO-8859, ISO-8859, ISO-8859, windows-1253, ISO-8859, windows-1255, ISO-8859, windows-1251, windows-1256, KOI8-R, ISO-8859, windows-1254, Shift_JIS, GB18030, EUC-JP, EUC-KR, Big5, ISO-2022, ISO-2022, ISO-2022
;;
;; Default ANSI charset to override non-UTF-8 charsets to
;ANSI_CHARSET =
+19 -3
View File
@@ -17,6 +17,8 @@ import (
"github.com/gogs/chardet"
"golang.org/x/net/html/charset"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/unicode/utf32"
"golang.org/x/text/transform"
)
@@ -28,12 +30,26 @@ var globalVars = sync.OnceValue(func() (ret struct {
invisibleRangeTable *unicode.RangeTable
},
) {
ret.utf8Bom = []byte{'\xef', '\xbb', '\xbf'}
ret.utf8Bom = []byte("\xef\xbb\xbf")
ret.ambiguousTableMap = newAmbiguousTableMap()
ret.invisibleRangeTable = newInvisibleRangeTable()
return ret
})
func Lookup(label string) (e encoding.Encoding, name string) {
e, name = charset.Lookup(label)
if e != nil {
return e, name
}
switch {
case strings.EqualFold(label, "UTF-32BE"):
return utf32.UTF32(utf32.BigEndian, utf32.IgnoreBOM), "UTF-32BE"
case strings.EqualFold(label, "UTF-32LE"):
return utf32.UTF32(utf32.LittleEndian, utf32.IgnoreBOM), "UTF-32LE"
}
return nil, ""
}
type ConvertOpts struct {
KeepBOM bool
ErrorReplacement []byte
@@ -57,7 +73,7 @@ func ToUTF8WithFallbackReader(rd io.Reader, opts ConvertOpts) io.Reader {
return io.MultiReader(bytes.NewReader(maybeRemoveBOM(buf[:n], opts)), rd)
}
encoding, _ := charset.Lookup(charsetLabel)
encoding, _ := Lookup(charsetLabel)
if encoding == nil {
// unknown charset, don't do any processing
return io.MultiReader(bytes.NewReader(buf[:n]), rd)
@@ -86,7 +102,7 @@ func ToUTF8(content []byte, opts ConvertOpts) []byte {
return maybeRemoveBOM(content, opts)
}
encoding, _ := charset.Lookup(charsetLabel)
encoding, _ := Lookup(charsetLabel)
if encoding == nil {
setting.PanicInDevOrTesting("unsupported detected charset %q, it shouldn't happen", charsetLabel)
if opts.ErrorReturnOrigin {
+7
View File
@@ -245,3 +245,10 @@ func TestToUTF8WithFallbackReader(t *testing.T) {
}
}
}
func TestDefaultDetectedCharsetsOrder(t *testing.T) {
for _, charsetName := range setting.DefaultDetectedCharsetsOrder() {
e, _ := Lookup(charsetName)
assert.NotNil(t, e, "charset %s is not registered", charsetName)
}
}
+7 -3
View File
@@ -171,11 +171,15 @@ func MakeRepositoryWebLink(repoURL *RepositoryURL) string {
case "http", "https":
return strings.TrimSuffix(repoURL.GitURL.String(), ".git")
case "ssh", "git+ssh":
hostname, _, _ := net.SplitHostPort(repoURL.GitURL.Host)
hostname = util.IfZero(hostname, repoURL.GitURL.Host)
// only get the hostname part (with IPv6 square brackets)
host, _, _ := net.SplitHostPort(repoURL.GitURL.Host)
host = strings.TrimSuffix(net.JoinHostPort(host, "dummy-port"), ":dummy-port")
// if failed to parse the host, use it as is
host = util.IfZero(host, repoURL.GitURL.Host)
urlPath := strings.TrimSuffix(repoURL.GitURL.Path, ".git")
urlPath = strings.TrimPrefix(urlPath, "/")
urlFull := fmt.Sprintf("https://%s/%s", hostname, urlPath)
urlFull := fmt.Sprintf("https://%s/%s", host, urlPath)
urlFull = strings.TrimSuffix(urlFull, "/")
return urlFull
}
+8
View File
@@ -264,4 +264,12 @@ func TestMakeRepositoryBaseLink(t *testing.T) {
u, err = ParseRepositoryURL(t.Context(), "git+ssh://other:123/owner/repo.git")
assert.NoError(t, err)
assert.Equal(t, "https://other/owner/repo", MakeRepositoryWebLink(u))
u, err = ParseRepositoryURL(t.Context(), "git+ssh://[::1]/owner/repo.git")
assert.NoError(t, err)
assert.Equal(t, "https://[::1]/owner/repo", MakeRepositoryWebLink(u))
u, err = ParseRepositoryURL(t.Context(), "git+ssh://[::1]:2222/owner/repo.git")
assert.NoError(t, err)
assert.Equal(t, "https://[::1]/owner/repo", MakeRepositoryWebLink(u))
}
+7 -3
View File
@@ -70,6 +70,7 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
"mi", "mn", "mo", "mtext", "mspace", "ms",
// layout elements
"mrow", "mfrac", "msqrt", "mroot", "mstyle", "merror", "mpadded", "mphantom",
"maction", // although MDN says "maction" is deprecated, we still need to allow it, otherwise, if it is removed, the layout will be wrong
// scripting elements
"msub", "msup", "msubsup", "munder", "mover", "munderover", "mmultiscripts", "mprescripts", "none",
// tabular elements
@@ -77,10 +78,11 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
// semantic annotations
"semantics", "annotation", "annotation-xml",
}
policy.AllowNoAttrs().OnElements(mathMLElements...) // most MathML elements carry no attributes
policy.AllowAttrs("display", "alttext").OnElements("math")
policy.AllowAttrs(
// global presentation attributes
"dir", "displaystyle", "mathbackground", "mathcolor", "mathsize", "mathvariant", "scriptlevel",
// global attributes
"dir", "displaystyle", "mathbackground", "mathcolor", "mathsize", "mathvariant", "scriptlevel", "intent", "arg",
// operator attributes
"accent", "accentunder", "fence", "form", "largeop", "lspace", "maxsize", "minsize", "movablelimits", "rspace", "separator", "stretchy", "symmetric",
// space and padding attributes
@@ -90,7 +92,9 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
// table attributes
"columnalign", "columnlines", "columnspacing", "frame", "framespacing", "rowalign", "rowlines", "rowspacing",
// cell attributes
"columnspan",
"columnspan", "rowspan",
// maction attributes
"actiontype", "selection",
// annotation attribute
"encoding",
).OnElements(mathMLElements...)
+4 -1
View File
@@ -62,7 +62,10 @@ func TestSanitizer(t *testing.T) {
`<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`, `<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`,
// MathML
`<math display="display" class="foo"><mi mathcolor="c" class="bar"></mi></math>`, `<math display="display"><mi mathcolor="c"></mi></math>`,
`<math display="block" class="foo"><mi mathcolor="c" class="bar"></mi></math>`, `<math display="block"><mi mathcolor="c"></mi></math>`,
`<math><mfrac><mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow><msqrt><mn>2</mn></msqrt></mfrac></math>`, `<math><mfrac><mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow><msqrt><mn>2</mn></msqrt></mfrac></math>`,
`<math><mtable><mtr><mtd rowspan="2" columnspan="2"><mn>1</mn></mtd></mtr></mtable></math>`, `<math><mtable><mtr><mtd rowspan="2" columnspan="2"><mn>1</mn></mtd></mtr></mtable></math>`,
`<math><maction actiontype="toggle" selection="2"><mi intent="power($b,$e)" arg="b">x</mi><mn>2</mn></maction></math>`, `<math><maction actiontype="toggle" selection="2"><mi intent="power($b,$e)" arg="b">x</mi><mn>2</mn></maction></math>`,
// Disallow dangerous url schemes
`<a href="javascript:alert('xss')">bad</a>`, `bad`,
+14
View File
@@ -4,11 +4,14 @@
package packages
import (
"errors"
"io"
"io/fs"
"net/url"
"path"
"strings"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/util"
@@ -47,6 +50,17 @@ func (s *ContentStore) Has(key BlobHash256Key) error {
return err
}
func (s *ContentStore) OptionalSize(key BlobHash256Key) (sz optional.Option[int64], _ error) {
st, err := s.store.Stat(KeyToRelativePath(key))
if errors.Is(err, fs.ErrNotExist) {
return sz, nil
}
if err != nil {
return sz, err
}
return optional.Some(st.Size()), nil
}
// Save stores a package blob
func (s *ContentStore) Save(key BlobHash256Key, r io.Reader, size int64) error {
_, err := s.store.Save(KeyToRelativePath(key), r, size)
+35 -35
View File
@@ -126,41 +126,7 @@ var (
TrustedSSHKeys []string `ini:"TRUSTED_SSH_KEYS"`
} `ini:"repository.signing"`
}{
DetectedCharsetsOrder: []string{
"UTF-8",
"UTF-16BE",
"UTF-16LE",
"UTF-32BE",
"UTF-32LE",
"ISO-8859-1",
"windows-1252",
"ISO-8859-2",
"windows-1250",
"ISO-8859-5",
"ISO-8859-6",
"ISO-8859-7",
"windows-1253",
"ISO-8859-8-I",
"windows-1255",
"ISO-8859-8",
"windows-1251",
"windows-1256",
"KOI8-R",
"ISO-8859-9",
"windows-1254",
"Shift_JIS",
"GB18030",
"EUC-JP",
"EUC-KR",
"Big5",
"ISO-2022-JP",
"ISO-2022-KR",
"ISO-2022-CN",
"IBM424_rtl",
"IBM424_ltr",
"IBM420_rtl",
"IBM420_ltr",
},
DetectedCharsetsOrder: DefaultDetectedCharsetsOrder(),
DetectedCharsetScore: map[string]int{},
AnsiCharset: "",
ForcePrivate: false,
@@ -294,6 +260,40 @@ var (
ScriptType = "bash"
)
func DefaultDetectedCharsetsOrder() []string {
return []string{
"UTF-8",
"UTF-16BE",
"UTF-16LE",
"UTF-32BE",
"UTF-32LE",
"ISO-8859-1",
"windows-1252",
"ISO-8859-2",
"windows-1250",
"ISO-8859-5",
"ISO-8859-6",
"ISO-8859-7",
"windows-1253",
"ISO-8859-8-I",
"windows-1255",
"ISO-8859-8",
"windows-1251",
"windows-1256",
"KOI8-R",
"ISO-8859-9",
"windows-1254",
"Shift_JIS",
"GB18030",
"EUC-JP",
"EUC-KR",
"Big5",
"ISO-2022-JP",
"ISO-2022-KR",
"ISO-2022-CN",
}
}
func loadRepositoryFrom(rootCfg ConfigProvider) {
var err error
// Determine and create root git repository path.
+1 -1
View File
@@ -256,5 +256,5 @@ func PanicInDevOrTesting(msg string, a ...any) {
if !IsProd || IsInTesting {
panic(fmt.Sprintf(msg, a...))
}
log.Error(msg, a...)
log.ErrorWithSkip(1, msg, a...)
}
+3 -3
View File
@@ -1514,10 +1514,10 @@ func Routes() *web.Router {
m.Get("/signing-key.pub", misc.SigningKeySSH)
m.Group("/topics", func() {
m.Combo("").Get(repo.ListTopics).
Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics)
Put(reqToken(), reqAdmin(), mustNotBeArchived, bind(api.RepoTopicOptions{}), repo.UpdateTopics)
m.Group("/{topic}", func() {
m.Combo("").Put(reqToken(), repo.AddTopic).
Delete(reqToken(), repo.DeleteTopic)
m.Combo("").Put(reqToken(), mustNotBeArchived, repo.AddTopic).
Delete(reqToken(), mustNotBeArchived, repo.DeleteTopic)
}, reqAdmin())
}, reqAnyRepoReader())
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
+1 -1
View File
@@ -1617,7 +1617,7 @@ func GetPullRequestFiles(ctx *context.APIContext) {
limit = max(limit, 0)
apiFiles := make([]*api.ChangedFile, 0, limit)
for i := start; i < start+limit; i++ {
for i := start; i < start+limit && i < len(diff.Files); i++ {
// refs/pull/1/head stores the HEAD commit ID, allowing all related commits to be found in the base repository.
// The head repository might have been deleted, so we should not rely on it here.
apiFiles = append(apiFiles, convert.ToChangedFile(diff.Files[i], pr.BaseRepo, endCommitID))
+6
View File
@@ -100,6 +100,8 @@ func UpdateTopics(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/invalidTopicsError"
// "423":
// "$ref": "#/responses/repoArchivedError"
form := web.GetForm(ctx).(*api.RepoTopicOptions)
topicNames := form.Topics
@@ -161,6 +163,8 @@ func AddTopic(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/invalidTopicsError"
// "423":
// "$ref": "#/responses/repoArchivedError"
topicName := strings.TrimSpace(strings.ToLower(ctx.PathParam("topic")))
@@ -228,6 +232,8 @@ func DeleteTopic(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/invalidTopicsError"
// "423":
// "$ref": "#/responses/repoArchivedError"
topicName := strings.TrimSpace(strings.ToLower(ctx.PathParam("topic")))
+1
View File
@@ -103,6 +103,7 @@ func Install(ctx *context.Context) {
}
form.RegisterConfirm = setting.Service.RegisterEmailConfirm
form.MailNotify = setting.Service.EnableNotifyMail
form.EnableUpdateChecker = setting.CfgProvider.Section("cron.update_checker").Key("ENABLED").MustBool(true)
form.EnableOpenIDSignIn = setting.Service.EnableOpenIDSignIn
form.EnableOpenIDSignUp = setting.Service.EnableOpenIDSignUp
+1 -1
View File
@@ -594,7 +594,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxDeleteBranch(ctx *context.Cont
isPullBranchDeletable, _ = git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.HeadBranch)
}
if isPullBranchDeletable && pull.HasMerged {
if isPullBranchDeletable && prInfo.issue.IsClosed {
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pull.HeadRepoID, pull.HeadBranch)
if err != nil {
ctx.ServerError("HasUnmergedPullRequestsByHeadInfo", err)
+1
View File
@@ -360,6 +360,7 @@ func (prInfo *pullRequestViewInfo) prepareViewInfo(ctx *context.Context, issue *
ctx.Data["BaseBranch"] = issue.PullRequest.BaseBranch
ctx.Data["HeadBranch"] = issue.PullRequest.HeadBranch
ctx.Data["HeadUserName"] = issue.PullRequest.MustHeadUserName(ctx)
ctx.Data["BaseName"] = issue.PullRequest.BaseRepo.OwnerName
if issue.PullRequest.HasMerged {
prInfo.prepareViewMergedPullInfo(ctx)
+1 -2
View File
@@ -12,7 +12,6 @@ import (
"strings"
"time"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/httplib"
"gitea.dev/modules/public"
"gitea.dev/modules/reqctx"
@@ -139,5 +138,5 @@ func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
if csp == "" {
return ""
}
return htmlutil.HTMLFormat(`<meta http-equiv="Content-Security-Policy" content="%s">`, csp)
return template.HTML(`<meta http-equiv="Content-Security-Policy" content="` + csp + `">`)
}
+1 -2
View File
@@ -42,7 +42,6 @@ import (
"github.com/alecthomas/chroma/v2"
"github.com/sergi/go-diff/diffmatchpatch"
stdcharset "golang.org/x/net/html/charset"
"golang.org/x/text/encoding"
"golang.org/x/text/transform"
)
@@ -889,7 +888,7 @@ parsingLoop:
}
charsetLabel, _ := charset.DetectEncoding(buffer.Bytes())
if charsetLabel != "UTF-8" {
charsetEncoding, _ := stdcharset.Lookup(charsetLabel)
charsetEncoding, _ := charset.Lookup(charsetLabel)
if charsetEncoding != nil {
diffLineTypeDecoders[lineType] = charsetEncoding.NewDecoder()
}
+37 -8
View File
@@ -262,6 +262,42 @@ func NewPackageBlob(hsr packages_module.HashedSizeReader) *packages_model.Packag
}
}
func GetOrSavePackageBlob(ctx context.Context, contentStore *packages_module.ContentStore, blob *packages_model.PackageBlob, data packages_module.HashedSizeReader) (_ *packages_model.PackageBlob, _ bool, retErr error) {
if blob.Size != data.Size() {
return nil, false, fmt.Errorf("size mismatch: blob size %d, data size %d", blob.Size, data.Size())
}
pb, existsInDatabase, err := packages_model.GetOrInsertBlob(ctx, blob)
if err != nil {
return nil, false, fmt.Errorf("unable to get or insert blob: %w", err)
}
defer func() {
if retErr != nil && !existsInDatabase {
if errDelete := packages_model.DeleteBlobByID(ctx, pb.ID); errDelete != nil {
log.Error("unable to delete blob from database after failed save in content store: %v", errDelete)
}
}
}()
var objSize optional.Option[int64]
storeKey := packages_module.BlobHash256Key(pb.HashSHA256)
if existsInDatabase {
// check if the blob file actually is valid in the content store
objSize, err = contentStore.OptionalSize(storeKey)
if err != nil {
return nil, false, fmt.Errorf("unable to check object size in content store: %w", err)
}
}
if objSize.ValueOrDefault(-1) != blob.Size {
if err := contentStore.Save(storeKey, data, data.Size()); err != nil {
return nil, false, fmt.Errorf("unable to save object in content store: %w", err)
}
}
// existsInDatabase controls the "roll back", if other errors happen later,
// the "non-existing (newly created)" blob will be deleted from the content store, but not if it already existed in the database.
return pb, existsInDatabase, nil
}
func addFileToPackageVersion(ctx context.Context, pv *packages_model.PackageVersion, pvi *PackageInfo, pfci *PackageFileCreationInfo) (*packages_model.PackageFile, *packages_model.PackageBlob, bool, error) {
if err := CheckSizeQuotaExceeded(ctx, pfci.Creator, pvi.Owner, pvi.PackageType, pfci.Data.Size()); err != nil {
return nil, nil, false, err
@@ -273,18 +309,11 @@ func addFileToPackageVersion(ctx context.Context, pv *packages_model.PackageVers
func addFileToPackageVersionUnchecked(ctx context.Context, pv *packages_model.PackageVersion, pfci *PackageFileCreationInfo) (*packages_model.PackageFile, *packages_model.PackageBlob, bool, error) {
log.Trace("Adding package file: %v, %s", pv.ID, pfci.Filename)
pb, exists, err := packages_model.GetOrInsertBlob(ctx, NewPackageBlob(pfci.Data))
pb, exists, err := GetOrSavePackageBlob(ctx, packages_module.NewContentStore(), NewPackageBlob(pfci.Data), pfci.Data)
if err != nil {
log.Error("Error inserting package blob: %v", err)
return nil, nil, false, err
}
if !exists {
contentStore := packages_module.NewContentStore()
if err := contentStore.Save(packages_module.BlobHash256Key(pb.HashSHA256), pfci.Data, pfci.Data.Size()); err != nil {
log.Error("Error saving package blob in content store: %v", err)
return nil, nil, false, err
}
}
if pfci.OverwriteExisting {
pf, err := packages_model.GetFileForVersionByName(ctx, pv.ID, pfci.Filename, pfci.CompositeKey)
+96
View File
@@ -0,0 +1,96 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package packages
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"testing"
packages_model "gitea.dev/models/packages"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
packages_module "gitea.dev/modules/packages"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestCreatePackageAndAddFileRestoresMissingBlobFile(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
uploadPackage := func(t *testing.T, user *user_model.User, name, filename string, data []byte) (*packages_model.PackageFile, error) {
buf, err := packages_module.CreateHashedBufferFromReader(bytes.NewReader(data))
require.NoError(t, err)
_, pf, err := CreatePackageAndAddFile(t.Context(),
&PackageCreationInfo{
PackageInfo: PackageInfo{
Owner: user,
PackageType: packages_model.TypeNuGet,
Name: name,
Version: "1.0.0",
},
SemverCompatible: true,
Creator: user,
},
&PackageFileCreationInfo{
PackageFileInfo: PackageFileInfo{
Filename: filename,
},
Creator: user,
Data: buf,
IsLead: true,
})
return pf, err
}
// This test data is from https://github.com/go-gitea/gitea/issues/39215, it doesn't really matter, actually.
// The key point is that if the blob object is missing in the content storage, it must be restored when uploaded again.
pkgData := test.WriteZipArchive(map[string]string{
"package.nuspec": "<package><metadata><id>nuget.repro</id><version>1.0.0</version></metadata></package>",
"lib/netstandard2.0/_._": "",
}).Bytes()
pkgDataSum := sha256.Sum256(pkgData)
key := packages_module.BlobHash256Key(hex.EncodeToString(pkgDataSum[:]))
contentStore := packages_module.NewContentStore()
// The initial upload writes the blob row and its file
pf1, err := uploadPackage(t, user, "nuget.repro", "nuget.repro.1.0.0.nupkg", pkgData)
require.NoError(t, err)
sz, err := contentStore.OptionalSize(key)
assert.NoError(t, err)
assert.EqualValues(t, len(pkgData), sz.ValueOrDefault(-1))
// Simulate the storage inconsistency: the blob row survives but its file is missing
require.NoError(t, contentStore.Delete(key))
sz, err = contentStore.OptionalSize(key)
assert.NoError(t, err)
assert.EqualValues(t, -1, sz.ValueOrDefault(-1))
// Publishing a package with identical content must restore the blob file
pf2, err := uploadPackage(t, user, "nuget.repro-copy", "nuget.repro-copy.1.0.0.nupkg", pkgData)
require.NoError(t, err)
sz, err = contentStore.OptionalSize(key)
assert.NoError(t, err)
assert.EqualValues(t, len(pkgData), sz.ValueOrDefault(-1))
// The blob file must be present and both packages must be downloadable
for _, pf := range []*packages_model.PackageFile{pf1, pf2} {
s, _, _, err := OpenFileForDownload(t.Context(), pf, http.MethodGet)
require.NoError(t, err)
respData, err := io.ReadAll(s)
require.NoError(t, err)
assert.NoError(t, s.Close())
assert.Equal(t, pkgData, respData)
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ func isErrBlameNotFoundOrNotEnoughLines(err error) bool {
return false
}
notFound := strings.HasPrefix(stdErr, "fatal: no such path")
notEnoughLines := strings.HasPrefix(stdErr, "fatal: file ") && strings.Contains(stdErr, " has only ") && strings.Contains(stdErr, " lines?")
notEnoughLines := strings.HasPrefix(stdErr, "fatal: file ") && strings.Contains(stdErr, " has only ") && strings.Contains(stdErr, " line")
return notFound || notEnoughLines
}
+2
View File
@@ -55,10 +55,12 @@
<tr>
<td>{{.Version.ID}}</td>
<td>
{{if .Owner}}
<a href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a>
{{if .Owner.Visibility.IsPrivate}}
<span class="tw-text-gold">{{svg "octicon-lock"}}</span>
{{end}}
{{end}}
</td>
<td>{{.Package.Type.Name}}</td>
<td class="gt-ellipsis tw-max-w-48">{{.Package.Name}}</td>
+3 -1
View File
@@ -47,10 +47,12 @@
<tr>
<td>{{.ID}}</td>
<td>
{{if .Owner}}
<a class="tw-break-anywhere" href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a>
{{if .Owner.Visibility.IsPrivate}}
<span class="tw-text-gold">{{svg "octicon-lock"}}</span>
{{end}}
{{end}}
</td>
<td>
<a class="tw-break-anywhere" href="{{.Link}}">{{.Name}}</a>
@@ -59,7 +61,7 @@
{{end}}
{{if .IsPrivate}}
<span class="ui basic label">{{ctx.Locale.Tr "repo.desc.private"}}</span>
{{else}}
{{else if .Owner}}
{{if .Owner.Visibility.IsPrivate}}
<span class="ui basic label">{{ctx.Locale.Tr "repo.desc.internal"}}</span>
{{end}}
+1 -1
View File
@@ -150,7 +150,7 @@
<div class="inline field">
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "install.enable_update_checker"}}</label>
<input name="enable_update_checker" type="checkbox">
<input name="enable_update_checker" type="checkbox" {{if .enable_update_checker}}checked{{end}}>
</div>
<span class="help">{{ctx.Locale.Tr "install.enable_update_checker_helper"}}</span>
</div>
+1 -1
View File
@@ -14,7 +14,7 @@
</div>
<div class="item-main">
<div class="item-title">
{{$title := or $run.Title (ctx.Locale.Tr "actions.runs.empty_commit_message")}}
{{$title := or $run.Title (ctx.Locale.TrString "actions.runs.empty_commit_message")}}
{{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $.Repository}}
</div>
<div class="item-body">
+9
View File
@@ -18058,6 +18058,9 @@
},
"422": {
"$ref": "#/responses/invalidTopicsError"
},
"423": {
"$ref": "#/responses/repoArchivedError"
}
}
}
@@ -18104,6 +18107,9 @@
},
"422": {
"$ref": "#/responses/invalidTopicsError"
},
"423": {
"$ref": "#/responses/repoArchivedError"
}
}
},
@@ -18148,6 +18154,9 @@
},
"422": {
"$ref": "#/responses/invalidTopicsError"
},
"423": {
"$ref": "#/responses/repoArchivedError"
}
}
}
+9
View File
@@ -30293,6 +30293,9 @@
},
"422": {
"$ref": "#/components/responses/invalidTopicsError"
},
"423": {
"$ref": "#/components/responses/repoArchivedError"
}
},
"summary": "Replace list of topics for a repository",
@@ -30342,6 +30345,9 @@
},
"422": {
"$ref": "#/components/responses/invalidTopicsError"
},
"423": {
"$ref": "#/components/responses/repoArchivedError"
}
},
"summary": "Delete a topic from a repository",
@@ -30389,6 +30395,9 @@
},
"422": {
"$ref": "#/components/responses/invalidTopicsError"
},
"423": {
"$ref": "#/components/responses/repoArchivedError"
}
},
"summary": "Add a topic to a repository",
+27
View File
@@ -186,3 +186,30 @@ func TestAPIRepoTopic(t *testing.T) {
AddTokenAuth(token4)
MakeRequest(t, req, http.StatusForbidden)
}
func TestAPIRepoTopicArchived(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 30}) // owner of the archived repo51
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 51})
assert.True(t, repo.IsArchived)
token := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteRepository)
// writing topics on an archived repo must be rejected, matching the web UI
req := NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s", user.Name, repo.Name, "archivedtopic").
AddTokenAuth(token)
MakeRequest(t, req, http.StatusLocked)
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s", user.Name, repo.Name, "archivedtopic").
AddTokenAuth(token)
MakeRequest(t, req, http.StatusLocked)
req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/topics", user.Name, repo.Name),
&api.RepoTopicOptions{Topics: []string{"archivedtopic"}}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusLocked)
// reading topics stays allowed on an archived repo
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/topics", user.Name, repo.Name)).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
+21 -11
View File
@@ -9,18 +9,24 @@ import (
"path"
"testing"
"gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func assertLinkPageComplete(t *testing.T, session *TestSession, link string) {
func assertLinkPageComplete(t *testing.T, session *TestSession, link string, containStrings ...string) {
req := NewRequest(t, "GET", link)
resp := session.MakeRequest(t, req, http.StatusOK)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()), "Page did not complete: "+link)
for _, s := range containStrings {
assert.Contains(t, resp.Body.String(), s, "Page does not contain expected string: "+s)
}
}
func TestLinks(t *testing.T) {
@@ -179,26 +185,30 @@ func testLinksAsUser(t *testing.T) {
func testLinksRepoCommon(t *testing.T) {
// repo1 has enabled almost features, so we can test most links
repoLink := "/user2/repo1"
links := []string{
"/actions",
"/packages",
"/projects",
err := db.Insert(t.Context(), &actions.ActionRun{Title: "", RepoID: 1})
require.NoError(t, err)
links := map[string][]string{
"/actions": {"(empty commit message)"},
"/packages": {},
"/projects": {},
}
// anonymous user
for _, link := range links {
assertLinkPageComplete(t, nil, repoLink+link)
for link, strs := range links {
assertLinkPageComplete(t, nil, repoLink+link, strs...)
}
// admin/owner user
session := loginUser(t, "user1")
for _, link := range links {
assertLinkPageComplete(t, session, repoLink+link)
for link, strs := range links {
assertLinkPageComplete(t, session, repoLink+link, strs...)
}
// non-admin non-owner user
session = loginUser(t, "user2")
for _, link := range links {
assertLinkPageComplete(t, session, repoLink+link)
for link, strs := range links {
assertLinkPageComplete(t, session, repoLink+link, strs...)
}
}
+46 -39
View File
@@ -81,20 +81,6 @@ func testPullMerge(t *testing.T, session *TestSession, user, repo, pullNum strin
return resp
}
func testPullCleanUp(t *testing.T, session *TestSession, user, repo, pullnum string) *httptest.ResponseRecorder {
req := NewRequest(t, "GET", "/"+path.Join(user, repo, "pulls", pullnum))
resp := session.MakeRequest(t, req, http.StatusOK)
// Click the little button to create a pull
htmlDoc := NewHTMLParser(t, resp.Body)
link, exists := htmlDoc.doc.Find(".timeline-item .delete-branch-after-merge").Attr("data-url")
assert.True(t, exists, "The template has changed, can not find delete button url")
req = NewRequest(t, "POST", link)
resp = session.MakeRequest(t, req, http.StatusOK)
return resp
}
func preparePullMergeWebhook(t *testing.T, repoID int64) {
require.NoError(t, db.TruncateBeans(t.Context(), &webhook.Webhook{}, &webhook.HookTask{}))
require.NoError(t, db.Insert(t.Context(), &webhook.Webhook{
@@ -267,7 +253,7 @@ func TestPullSquashWithHeadCommitID(t *testing.T) {
})
}
func TestPullCleanUpAfterMerge(t *testing.T) {
func TestPullCleanUpAfterClose(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
session := loginUser(t, "user1") // FIXME: don't use admin user for testing
testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "")
@@ -277,39 +263,60 @@ func TestPullCleanUpAfterMerge(t *testing.T) {
assert.Equal(t, 3, repo.NumPulls)
assert.Equal(t, 3, repo.NumOpenPulls)
resp := testPullCreate(t, session, "user1", "repo1", false, "master", "feature/test", "This is a pull title")
getDeleteBranchLink := func(t *testing.T, session *TestSession, user, repo, pullnum string) string {
req := NewRequest(t, "GET", "/"+path.Join(user, repo, "pulls", pullnum))
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
return htmlDoc.doc.Find(".timeline-item .delete-branch-after-merge").AttrOr("data-url", "")
}
elem := strings.Split(test.RedirectURL(resp), "/")
assert.Equal(t, "pulls", elem[3])
var closedPullNumStr string
t.Run("CreateAndClosePR", func(t *testing.T) {
resp := testPullCreate(t, session, "user1", "repo1", false, "master", "feature/test", "This is a pull title")
pullNumStr := path.Base(test.RedirectURL(resp))
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.Equal(t, 4, repo.NumPulls)
assert.Equal(t, 4, repo.NumOpenPulls)
testIssueClose(t, session, "user2", "repo1", pullNumStr)
testPullMerge(t, session, elem[1], elem[2], elem[4], MergeOptions{
Style: repo_model.MergeStyleMerge,
DeleteBranch: false,
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.Equal(t, 4, repo.NumPulls)
assert.Equal(t, 3, repo.NumOpenPulls)
closedPullNumStr = pullNumStr
// the closed but unmerged PR should have the "delete branch" button
link := getDeleteBranchLink(t, session, "user2", "repo1", closedPullNumStr)
assert.NotEmpty(t, link)
})
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
assert.Equal(t, 4, repo.NumPulls)
assert.Equal(t, 3, repo.NumOpenPulls)
t.Run("CreateAndMergePR", func(t *testing.T) {
resp := testPullCreate(t, session, "user1", "repo1", false, "master", "feature/test", "This is a pull title")
pullNumStr := path.Base(test.RedirectURL(resp))
// Check PR branch deletion
resp = testPullCleanUp(t, session, elem[1], elem[2], elem[4])
respJSON := test.ParseJSONRedirect(resp.Body.Bytes())
require.NotEmpty(t, respJSON.Redirect, "Redirected URL is not found")
// the closed but unmerged PR should not have the "delete branch" button because there is a new PR for the same branch
link := getDeleteBranchLink(t, session, "user2", "repo1", closedPullNumStr)
assert.Empty(t, link)
elem = strings.Split(*respJSON.Redirect, "/")
assert.Equal(t, "pulls", elem[3])
testPullMerge(t, session, "user2", "repo1", pullNumStr, MergeOptions{
Style: repo_model.MergeStyleMerge,
DeleteBranch: false,
})
// Check branch deletion result
req := NewRequest(t, "GET", *respJSON.Redirect)
resp = session.MakeRequest(t, req, http.StatusOK)
// Check PR branch deletion
link = getDeleteBranchLink(t, session, "user2", "repo1", pullNumStr)
assert.NotEmpty(t, link)
resp = session.MakeRequest(t, NewRequest(t, "POST", link), http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
resultMsg := strings.TrimSpace(htmlDoc.doc.Find(".ui.message.flash-message").Text())
assert.Equal(t, `Branch "user1/repo1:feature/test" has been deleted.`, resultMsg)
// Check branch deletion result
req := NewRequest(t, "GET", test.RedirectURL(resp))
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
resultMsg := strings.TrimSpace(htmlDoc.doc.Find(".ui.message.flash-message").Text())
assert.Equal(t, `Branch "user1/repo1:feature/test" has been deleted.`, resultMsg)
// the "delete branch" button should be gone since the PR has been merged
link = getDeleteBranchLink(t, session, "user2", "repo1", pullNumStr)
assert.Empty(t, link)
})
})
}
+1 -1
View File
@@ -132,7 +132,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
const file = {name: attachment.name, uuid: attachment.uuid, size: attachment.size};
dzInst.emit('addedfile', file);
dzInst.emit('complete', file);
if (isImageFile(file.name)) {
if (isImageFile(file)) {
const imgSrc = `${attachmentBaseLinkUrl}/${file.uuid}`;
dzInst.emit('thumbnail', file, imgSrc);
}
+4 -3
View File
@@ -66,9 +66,9 @@ function onModalBeforeHidden(this: any) {
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');
const elModal = $modal[0] as HTMLElement;
const elApprove = elModal.querySelector<HTMLElement>(selectors.approve);
const elForm = elApprove?.closest<HTMLFormElement>('form');
if (!elForm) return true; // no form, just allow closing the modal
// "form-fetch-action" can handle network errors gracefully,
@@ -78,6 +78,7 @@ function onModalApproveDefault(this: any) {
// 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.
if (!elForm.reportValidity()) return false;
elForm.classList.add('is-loading');
return false;
}