Compare commits

..

16 Commits

Author SHA1 Message Date
Giteabot
17499a5de6 fix(migrations): stop endless comment paging when migrating from Gitea (#39419) (#39421)
Backport #39419 by @perfectra1n

`GET /repos/{owner}/{repo}/issues/{index}/comments` ignores `page` and
`limit` and always returns every comment. The Gitea downloader pages it
and stops only on a short page, so migrating from Gitea or Forgejo loops
forever on any issue with at least `MAX_RESPONSE_ITEMS` comments,
without an error.

Paging is kept in case the endpoint gets paginated
(https://github.com/go-gitea/gitea/issues/6132,
https://github.com/go-gitea/gitea/issues/18082). The loop now stops when
a page is longer than the limit or starts with an already seen comment.

Prior art: Forgejo fixed its copy in
https://codeberg.org/forgejo/forgejo/pulls/9274 (report:
https://codeberg.org/Codeberg/Community/issues/1542).

---
AI-assisted: drafted with Claude Code (claude-opus-5-5), reviewed by me.

Please let me know if you have any suggestions or comments, I ran into
this issue myself when I was trying to migrate repositories from Forgejo
-> Gitea.

Co-authored-by: Jon Fuller <jonfuller2012@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-24 18:56:08 -07:00
Giteabot
a1a092445d fix(issues): don't render team review requests as Ghost (#39416) (#39418)
Backport #39416 by @bircni

Closes https://github.com/go-gitea/gitea/issues/39414

`CommentList.loadAssignees` set a Ghost assignee on every comment
without an `AssigneeID` whenever another comment in the same batch had
one. Since https://github.com/go-gitea/gitea/pull/38413 the timeline
prefers `Assignee` over `AssigneeTeam`, so team review requests, from
CODEOWNERS or added manually, rendered as "Ghost" whenever the timeline
also had a user assignee or user review request. Skip comments without
an `AssigneeID`.

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-09-24 19:36:06 +00:00
wxiaoguang
6d600e64b5 fix: use correct content-type for container registry response (#39398) (#39407) 2026-09-24 15:01:43 +08:00
Giteabot
e3a43af379 fix(user): allow unblocking users promoted to admin (#39192) (#39402)
Backport #39192 by @loveulvu

Fixes #39189.

`IsUserBlockedBy` intentionally treats admin users as not blocked, but
`CanUnblockUser` was also using it to determine whether a blocking
relationship exists. If a previously blocked user is later promoted to
admin, the existing `user_blocking` record remains but can no longer be
removed.

This change separates those two concerns by adding `HasBlocking` for
checking the persisted blocking relationship. `CanUnblockUser` uses that
relationship check while `IsUserBlockedBy` keeps its existing admin-user
behavior.

A regression test verifies that an admin is still not considered blocked
while an existing blocking relationship can still be unblocked.

Tests:
- `go test ./models/user ./services/user -count=1`

Co-authored-by: Even <412401474@qq.com>
2026-09-23 20:26:48 +02:00
wxiaoguang
3298b8b62c fix: "install" page (#39400) (#39401)
partially backport #39400

avod writing corrupted ini config
2026-09-23 15:06:50 +00:00
Giteabot
0dc8bf8848 fix(markup): raise KaTeX MAX_CHARS limit to 10000 (#39387) (#39389)
Backport #39387 by @Copilot

Fix #39386
2026-09-22 17:55:03 +02:00
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
42 changed files with 535 additions and 202 deletions

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 =

View File

@@ -201,6 +201,9 @@ func (comments CommentList) loadAssignees(ctx context.Context) error {
}
for _, comment := range comments {
if comment.AssigneeID <= 0 {
continue
}
comment.Assignee = assignees[comment.AssigneeID]
if comment.Assignee == nil {
comment.AssigneeID = user_model.GhostUserID

View File

@@ -58,6 +58,20 @@ func TestLoadAssigneeUserAndTeam_DeletedTeamBecomesGhostTeam(t *testing.T) {
assert.EqualValues(t, -1, comment.AssigneeTeam.ID)
}
func TestCommentListLoadAttributesMixedAssignees(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
comments := issues_model.CommentList{
{AssigneeTeamID: 8},
{AssigneeID: 999999},
}
assert.NoError(t, comments.LoadAttributes(t.Context()))
assert.Nil(t, comments[0].Assignee)
assert.Zero(t, comments[0].AssigneeID)
assert.Equal(t, user_model.GhostUserID, comments[1].AssigneeID)
assert.Equal(t, user_model.GhostUserID, comments[1].Assignee.ID)
}
func Test_UpdateCommentAttachment(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

View File

@@ -45,15 +45,20 @@ func UpdateBlockingNote(ctx context.Context, id int64, note string) error {
}
func IsUserBlockedBy(ctx context.Context, blockee *User, blockerIDs ...int64) bool {
if len(blockerIDs) == 0 {
return false
}
if blockee.IsAdmin {
return false
}
cond := builder.Eq{"user_blocking.blockee_id": blockee.ID}.
return HasBlocking(ctx, blockee.ID, blockerIDs...)
}
// HasBlocking reports whether a blocking relationship exists regardless of the blockee's admin status.
func HasBlocking(ctx context.Context, blockeeID int64, blockerIDs ...int64) bool {
if len(blockerIDs) == 0 {
return false
}
cond := builder.Eq{"user_blocking.blockee_id": blockeeID}.
And(builder.In("user_blocking.blocker_id", blockerIDs))
has, _ := db.GetEngine(ctx).Where(cond).Exist(&Blocking{})

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 {

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

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
}

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

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...)

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`,

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)

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.

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

View File

@@ -57,21 +57,17 @@ type containerHeaders struct {
UploadUUID string
Range string
Location string
ContentType string
ContentLength optional.Option[int64]
}
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#legacy-docker-support-http-headers
func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) {
func writeResponseHeaders(resp http.ResponseWriter, h *containerHeaders) {
if h.Location != "" {
resp.Header().Set("Location", h.Location)
}
if h.Range != "" {
resp.Header().Set("Range", h.Range)
}
if h.ContentType != "" {
resp.Header().Set("Content-Type", h.ContentType)
}
if h.ContentLength.Has() {
resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength.Value(), 10))
}
@@ -87,18 +83,14 @@ func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) {
}
func jsonResponse(ctx *context.Context, status int, obj any) {
setResponseHeaders(ctx.Resp, &containerHeaders{
Status: status,
ContentType: "application/json",
})
ctx.Resp.Header().Set("Content-Type", "application/json")
writeResponseHeaders(ctx.Resp, &containerHeaders{Status: status})
_ = json.NewEncoder(ctx.Resp).Encode(obj) // ignore network errors
}
func apiError(ctx *context.Context, status int, err error) {
_ = helper.ProcessErrorForUser(ctx, status, err)
setResponseHeaders(ctx.Resp, &containerHeaders{
Status: status,
})
writeResponseHeaders(ctx.Resp, &containerHeaders{Status: status})
}
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes
@@ -155,9 +147,7 @@ func VerifyImageName(ctx *context.Context) {
// DetermineSupport is used to test if the registry supports OCI
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support
func DetermineSupport(ctx *context.Context) {
setResponseHeaders(ctx.Resp, &containerHeaders{
Status: http.StatusOK,
})
writeResponseHeaders(ctx.Resp, &containerHeaders{Status: http.StatusOK})
}
// Authenticate creates a token for the current user
@@ -267,7 +257,7 @@ func PostBlobsUploads(ctx *context.Context) {
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
writeResponseHeaders(ctx.Resp, &containerHeaders{
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, mount),
ContentDigest: mount,
Status: http.StatusCreated,
@@ -310,7 +300,7 @@ func PostBlobsUploads(ctx *context.Context) {
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
writeResponseHeaders(ctx.Resp, &containerHeaders{
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest),
ContentDigest: digest,
Status: http.StatusCreated,
@@ -324,7 +314,7 @@ func PostBlobsUploads(ctx *context.Context) {
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
writeResponseHeaders(ctx.Resp, &containerHeaders{
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID),
UploadUUID: upload.ID,
Status: http.StatusAccepted,
@@ -355,7 +345,7 @@ func GetBlobsUpload(ctx *context.Context) {
if upload.BytesReceived > 0 {
respHeaders.Range = fmt.Sprintf("0-%d", upload.BytesReceived-1)
}
setResponseHeaders(ctx.Resp, respHeaders)
writeResponseHeaders(ctx.Resp, respHeaders)
}
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post
@@ -404,7 +394,7 @@ func PatchBlobsUpload(ctx *context.Context) {
if uploader.Size() > 0 {
respHeaders.Range = fmt.Sprintf("0-%d", uploader.Size()-1)
}
setResponseHeaders(ctx.Resp, respHeaders)
writeResponseHeaders(ctx.Resp, respHeaders)
}
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks
@@ -469,7 +459,7 @@ func PutBlobsUpload(ctx *context.Context) {
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
writeResponseHeaders(ctx.Resp, &containerHeaders{
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest),
ContentDigest: digest,
Status: http.StatusCreated,
@@ -495,9 +485,7 @@ func DeleteBlobsUpload(ctx *context.Context) {
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
Status: http.StatusNoContent,
})
writeResponseHeaders(ctx.Resp, &containerHeaders{Status: http.StatusNoContent})
}
func getBlobFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) {
@@ -524,8 +512,8 @@ func HeadBlob(ctx *context.Context) {
}
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
ctx.Resp.Header().Set("Content-Type", "application/octet-stream")
writeResponseHeaders(ctx.Resp, &containerHeaders{
ContentDigest: blob.Properties.GetByName(container_module.PropertyDigest),
ContentLength: optional.Some(blob.Blob.Size),
Status: http.StatusOK,
@@ -543,8 +531,9 @@ func GetBlob(ctx *context.Context) {
}
return
}
serveBlob(ctx, blob)
// "/v2/<name>/blobs/<digest>" : no need to use descriptor.mediaType, just respond as binary content.
// don't trust or use the blob's media type, it is not validated.
serveBlob(ctx, blob, "application/octet-stream")
}
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-blobs
@@ -560,9 +549,7 @@ func DeleteBlob(ctx *context.Context) {
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
Status: http.StatusAccepted,
})
writeResponseHeaders(ctx.Resp, &containerHeaders{Status: http.StatusAccepted})
}
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests
@@ -611,7 +598,7 @@ func PutManifest(ctx *context.Context) {
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
writeResponseHeaders(ctx.Resp, &containerHeaders{
Location: fmt.Sprintf("/v2/%s/%s/manifests/%s", ctx.Package.Owner.LowerName, mci.Image, reference),
ContentDigest: digest,
Status: http.StatusCreated,
@@ -638,18 +625,25 @@ func getBlobSearchOptionsFromContext(ctx *context.Context) (*container_model.Blo
return opts, nil
}
func getManifestFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) {
func getManifestFromContext(ctx *context.Context) (_ *packages_model.PackageFileDescriptor, contentType string, _ error) {
opts, err := getBlobSearchOptionsFromContext(ctx)
if err != nil {
return nil, err
return nil, "", err
}
return workaroundGetContainerBlob(ctx, opts)
pfd, err := workaroundGetContainerBlob(ctx, opts)
if err != nil {
return nil, "", err
}
// "/v2/<name>/manifests/<reference>": must use valid manifest.mediaType as HTTP Content-Type
mediaType := pfd.Properties.GetByName(container_module.PropertyMediaType)
mediaType = util.Iif(container_module.IsMediaTypeValid(mediaType), mediaType, "application/octet-stream")
return pfd, mediaType, nil
}
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry
func HeadManifest(ctx *context.Context) {
manifest, err := getManifestFromContext(ctx)
manifest, contentType, err := getManifestFromContext(ctx)
if err != nil {
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
apiErrorDefined(ctx, errManifestUnknown)
@@ -658,10 +652,9 @@ func HeadManifest(ctx *context.Context) {
}
return
}
setResponseHeaders(ctx.Resp, &containerHeaders{
ctx.Resp.Header().Set("Content-Type", contentType)
writeResponseHeaders(ctx.Resp, &containerHeaders{
ContentDigest: manifest.Properties.GetByName(container_module.PropertyDigest),
ContentType: manifest.Properties.GetByName(container_module.PropertyMediaType),
ContentLength: optional.Some(manifest.Blob.Size),
Status: http.StatusOK,
})
@@ -669,7 +662,7 @@ func HeadManifest(ctx *context.Context) {
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
func GetManifest(ctx *context.Context) {
manifest, err := getManifestFromContext(ctx)
manifest, contentType, err := getManifestFromContext(ctx)
if err != nil {
if errors.Is(err, container_model.ErrContainerBlobNotExist) {
apiErrorDefined(ctx, errManifestUnknown)
@@ -678,8 +671,7 @@ func GetManifest(ctx *context.Context) {
}
return
}
serveBlob(ctx, manifest)
serveBlob(ctx, manifest, contentType)
}
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-tags
@@ -709,38 +701,34 @@ func DeleteManifest(ctx *context.Context) {
}
}
setResponseHeaders(ctx.Resp, &containerHeaders{
Status: http.StatusAccepted,
})
writeResponseHeaders(ctx.Resp, &containerHeaders{Status: http.StatusAccepted})
}
func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) {
func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor, contentType string) {
s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, &storage.ServeDirectOptions{
ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
ContentType: contentType,
})
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
}
headers := &containerHeaders{
ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest),
ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
ContentLength: optional.Some(pfd.Blob.Size),
Status: http.StatusOK,
}
if u != nil {
headers.Status = http.StatusTemporaryRedirect
headers.Location = u.String()
headers.ContentLength = optional.None[int64]() // do not set Content-Length for redirect responses
setResponseHeaders(ctx.Resp, headers)
writeResponseHeaders(ctx.Resp, &containerHeaders{
ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest), // legacy logic: not sure whether it is right when redirecting
Location: u.String(),
Status: http.StatusTemporaryRedirect,
})
return
}
defer s.Close()
setResponseHeaders(ctx.Resp, headers)
ctx.Resp.Header().Set("Content-Type", contentType)
writeResponseHeaders(ctx.Resp, &containerHeaders{
ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest),
ContentLength: optional.Some(pfd.Blob.Size),
Status: http.StatusOK,
})
_, _ = io.Copy(ctx.Resp, s)
}

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)

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

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")))

View File

@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
@@ -103,6 +104,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
@@ -179,6 +181,17 @@ func checkDatabase(ctx *context.Context, form *forms.InstallForm) bool {
return true
}
func fillInstallConfigCleanUp(cfg setting.ConfigProvider) {
re := regexp.MustCompile(`[\x00-\x1F\x7F]`)
for _, sec := range cfg.Sections() {
for _, key := range sec.Keys() {
s := key.String()
s = re.ReplaceAllString(s, " ")
key.SetValue(s)
}
}
}
// SubmitInstall response for submit install items
func SubmitInstall(ctx *context.Context) {
if setting.InstallLock {
@@ -451,6 +464,7 @@ func SubmitInstall(ctx *context.Context) {
setting.EnvironmentToConfig(cfg, os.Environ())
fillInstallConfigCleanUp(cfg)
if err = cfg.SaveTo(setting.CustomConf); err != nil {
ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
return

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)

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)

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 + `">`)
}

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

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
base "gitea.dev/modules/migration"
"gitea.dev/modules/structs"
@@ -472,6 +473,7 @@ func (g *GiteaDownloader) GetIssues(ctx context.Context, page, perPage int) ([]*
// GetComments returns comments according issueNumber
func (g *GiteaDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
allComments := make([]*base.Comment, 0, g.maxPerPage)
seenIDs := container.Set[int64]{}
for i := 1; ; i++ {
// make sure gitea can shutdown gracefully
@@ -488,8 +490,12 @@ func (g *GiteaDownloader) GetComments(ctx context.Context, commentable base.Comm
if err != nil {
return nil, false, fmt.Errorf("error while listing comments for issue #%d. Error: %w", commentable.GetForeignIndex(), err)
}
if len(comments) == 0 || seenIDs.Contains(comments[0].ID) {
break // the endpoint ignores page and limit, so page 2 repeats page 1
}
for _, comment := range comments {
seenIDs.Add(comment.ID)
reactions, err := g.getCommentReactions(comment.ID)
if err != nil {
WarnAndNotice("Unable to load comment reactions during migrating issue #%d for comment %d in %s. Error: %v", commentable.GetForeignIndex(), comment.ID, g, err)
@@ -508,7 +514,7 @@ func (g *GiteaDownloader) GetComments(ctx context.Context, commentable base.Comm
})
}
if !g.pagination || len(comments) < g.maxPerPage {
if !g.pagination || len(comments) != g.maxPerPage {
break
}
}

View File

@@ -4,10 +4,15 @@
package migrations
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"testing"
"time"
@@ -309,3 +314,49 @@ func TestGiteaDownloadRepo(t *testing.T) {
},
}, reviews)
}
func TestGiteaDownloadCommentsPaging(t *testing.T) {
for _, tc := range []struct {
maxResponseItems, commentCount, requests int
paginated bool
}{
{maxResponseItems: 2, commentCount: 2, requests: 2},
{maxResponseItems: 2, commentCount: 3, requests: 1},
{maxResponseItems: 2, commentCount: 4, requests: 3, paginated: true},
{maxResponseItems: 0, commentCount: 0, requests: 1},
} {
t.Run(strconv.Itoa(tc.commentCount), func(t *testing.T) {
commentRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
_, _ = w.Write([]byte(`{"version":"1.27.0"}`))
case "/api/v1/settings/api":
_, _ = fmt.Fprintf(w, `{"max_response_items":%d}`, tc.maxResponseItems)
case "/api/v1/repos/o/r/issues/1/comments":
commentRequests++
comments := make([]string, tc.commentCount)
for i := range comments {
comments[i] = fmt.Sprintf(`{"id":%d,"user":{}}`, i+1)
}
if tc.paginated {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
comments = comments[(page-1)*tc.maxResponseItems : min(page*tc.maxResponseItems, len(comments))]
}
_, _ = w.Write([]byte("[" + strings.Join(comments, ",") + "]"))
default:
_, _ = w.Write([]byte(`[]`))
}
}))
defer server.Close()
downloader, err := NewGiteaDownloader(t.Context(), server.URL, "o/r", "", "", "")
require.NoError(t, err)
comments, _, err := downloader.GetComments(t.Context(), &base.Issue{Number: 1})
require.NoError(t, err)
assert.Len(t, comments, tc.commentCount)
assert.Equal(t, tc.requests, commentRequests)
})
}
}

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)

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

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
}

View File

@@ -50,7 +50,7 @@ func CanUnblockUser(ctx context.Context, doer, blocker, blockee *user_model.User
return false
}
if !user_model.IsUserBlockedBy(ctx, blockee, blocker.ID) {
if !user_model.HasBlocking(ctx, blockee.ID, blocker.ID) {
return false
}

View File

@@ -62,4 +62,8 @@ func TestCanUnblockUser(t *testing.T) {
assert.True(t, CanUnblockUser(t.Context(), user1, user2, user29))
assert.True(t, CanUnblockUser(t.Context(), user2, user2, user29))
assert.True(t, CanUnblockUser(t.Context(), user1, org17, user28))
// Existing block can still be removed after the blockee becomes an admin.
user29.IsAdmin = true
assert.False(t, user_model.IsUserBlockedBy(t.Context(), user29, user2.ID))
assert.True(t, CanUnblockUser(t.Context(), user2, user2, user29))
}

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>

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

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>

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">

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"
}
}
}

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",

View File

@@ -610,33 +610,28 @@ func TestPackageContainer(t *testing.T) {
t.Run("HeadBlob", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, unknownDigest)).
AddTokenAuth(userToken)
req := NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, unknownDigest)).AddTokenAuth(userToken)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
AddTokenAuth(userToken)
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).AddTokenAuth(userToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "application/octet-stream", resp.Header().Get("Content-Type"))
assert.Equal(t, strconv.Itoa(len(blobContent)), resp.Header().Get("Content-Length"))
assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest"))
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
AddTokenAuth(anonymousToken)
req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).AddTokenAuth(anonymousToken)
MakeRequest(t, req, http.StatusOK)
})
t.Run("GetBlob", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", fmt.Sprintf("%s/blobs/%s", url, unknownDigest)).
AddTokenAuth(userToken)
req := NewRequest(t, "GET", fmt.Sprintf("%s/blobs/%s", url, unknownDigest)).AddTokenAuth(userToken)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequest(t, "GET", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
AddTokenAuth(userToken)
req = NewRequest(t, "GET", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).AddTokenAuth(userToken)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "application/octet-stream", resp.Header().Get("Content-Type"))
assert.Equal(t, strconv.Itoa(len(blobContent)), resp.Header().Get("Content-Length"))
assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest"))
assert.Equal(t, blobContent, resp.Body.Bytes())

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

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

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

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

View File

@@ -20,7 +20,7 @@ export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> {
import('katex/dist/katex.css'),
]);
const MAX_CHARS = 1000;
const MAX_CHARS = 10000;
const MAX_SIZE = 25;
const MAX_EXPAND = 1000;

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;
}