diff --git a/models/activities/action.go b/models/activities/action.go index e74deef1df4..546d4340aed 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -770,7 +770,7 @@ func DeleteIssueActions(ctx context.Context, repoID, issueID, issueIndex int64) // CountActionCreatedUnixString count actions where created_unix is an empty string func CountActionCreatedUnixString(ctx context.Context) (int64, error) { if setting.Database.Type.IsSQLite3() { - return db.GetEngine(ctx).Where(`created_unix = ""`).Count(new(Action)) + return db.GetEngine(ctx).Where(`created_unix = ''`).Count(new(Action)) } return 0, nil } @@ -778,7 +778,7 @@ func CountActionCreatedUnixString(ctx context.Context) (int64, error) { // FixActionCreatedUnixString set created_unix to zero if it is an empty string func FixActionCreatedUnixString(ctx context.Context) (int64, error) { if setting.Database.Type.IsSQLite3() { - res, err := db.GetEngine(ctx).Exec(`UPDATE action SET created_unix = 0 WHERE created_unix = ""`) + res, err := db.GetEngine(ctx).Exec(`UPDATE action SET created_unix = 0 WHERE created_unix = ''`) if err != nil { return 0, err } diff --git a/models/activities/action_test.go b/models/activities/action_test.go index e5dee33ae02..64330ebbb3e 100644 --- a/models/activities/action_test.go +++ b/models/activities/action_test.go @@ -256,7 +256,7 @@ func TestConsistencyUpdateAction(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &activities_model.Action{ ID: int64(id), }) - _, err := db.GetEngine(db.DefaultContext).Exec(`UPDATE action SET created_unix = "" WHERE id = ?`, id) + _, err := db.GetEngine(db.DefaultContext).Exec(`UPDATE action SET created_unix = '' WHERE id = ?`, id) assert.NoError(t, err) actions := make([]*activities_model.Action, 0, 1) // diff --git a/models/issues/comment.go b/models/issues/comment.go index 48b8e335d48..6650817e506 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -1108,7 +1108,7 @@ func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, sess.Join("INNER", "issue", "issue.id = comment.issue_id") } - if opts.Page != 0 { + if opts.Page > 0 { sess = db.SetSessionPagination(sess, opts) } diff --git a/models/issues/issue.go b/models/issues/issue.go index 9ccf2859ea4..fd89267b84d 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -641,7 +641,7 @@ func (issue *Issue) BlockedByDependencies(ctx context.Context, opts db.ListOptio Where("issue_id = ?", issue.ID). // sort by repo id then created date, with the issues of the same repo at the beginning of the list OrderBy("CASE WHEN issue.repo_id = ? THEN 0 ELSE issue.repo_id END, issue.created_unix DESC", issue.RepoID) - if opts.Page != 0 { + if opts.Page > 0 { sess = db.SetSessionPagination(sess, &opts) } err = sess.Find(&issueDeps) diff --git a/models/issues/issue_watch.go b/models/issues/issue_watch.go index 9e616a0eb13..560be17eb62 100644 --- a/models/issues/issue_watch.go +++ b/models/issues/issue_watch.go @@ -105,7 +105,7 @@ func GetIssueWatchers(ctx context.Context, issueID int64, listOptions db.ListOpt And("`user`.prohibit_login = ?", false). Join("INNER", "`user`", "`user`.id = `issue_watch`.user_id") - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) watches := make([]*IssueWatch, 0, listOptions.PageSize) return watches, sess.Find(&watches) diff --git a/models/issues/label.go b/models/issues/label.go index 2530f71004d..d80578193e5 100644 --- a/models/issues/label.go +++ b/models/issues/label.go @@ -390,7 +390,7 @@ func GetLabelsByRepoID(ctx context.Context, repoID int64, sortType string, listO sess.Asc("name") } - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) } @@ -462,7 +462,7 @@ func GetLabelsByOrgID(ctx context.Context, orgID int64, sortType string, listOpt sess.Asc("name") } - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) } diff --git a/models/issues/label_test.go b/models/issues/label_test.go index a0cc8e6d756..c2ff084c236 100644 --- a/models/issues/label_test.go +++ b/models/issues/label_test.go @@ -406,7 +406,7 @@ func TestDeleteIssueLabel(t *testing.T) { PosterID: doerID, IssueID: issueID, LabelID: labelID, - }, `content=""`) + }, `content=''`) label = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: labelID}) assert.EqualValues(t, expectedNumIssues, label.NumIssues) assert.EqualValues(t, expectedNumClosedIssues, label.NumClosedIssues) diff --git a/models/issues/reaction.go b/models/issues/reaction.go index eb7faefc796..11b3c6be203 100644 --- a/models/issues/reaction.go +++ b/models/issues/reaction.go @@ -163,7 +163,7 @@ func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList Where(opts.toConds()). In("reaction.`type`", setting.UI.Reactions). Asc("reaction.issue_id", "reaction.comment_id", "reaction.created_unix", "reaction.id") - if opts.Page != 0 { + if opts.Page > 0 { sess = db.SetSessionPagination(sess, &opts) reactions := make([]*Reaction, 0, opts.PageSize) diff --git a/models/issues/stopwatch.go b/models/issues/stopwatch.go index fd9c7d78755..629af95b577 100644 --- a/models/issues/stopwatch.go +++ b/models/issues/stopwatch.go @@ -96,7 +96,7 @@ func GetUIDsAndStopwatch(ctx context.Context) ([]*UserStopwatch, error) { func GetUserStopwatches(ctx context.Context, userID int64, listOptions db.ListOptions) ([]*Stopwatch, error) { sws := make([]*Stopwatch, 0, 8) sess := db.GetEngine(ctx).Where("stopwatch.user_id = ?", userID) - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) } diff --git a/models/issues/tracked_time.go b/models/issues/tracked_time.go index caa582a9fca..ea404d36cd1 100644 --- a/models/issues/tracked_time.go +++ b/models/issues/tracked_time.go @@ -139,7 +139,7 @@ func (opts *FindTrackedTimesOptions) toSession(e db.Engine) db.Engine { sess = sess.Where(opts.ToConds()) - if opts.Page != 0 { + if opts.Page > 0 { sess = db.SetSessionPagination(sess, opts) } diff --git a/models/user/search.go b/models/user/search.go index 382b6fac2b0..6af33892373 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -152,7 +152,7 @@ func SearchUsers(ctx context.Context, opts *SearchUserOptions) (users []*User, _ sessQuery := opts.toSearchQueryBase(ctx).OrderBy(opts.OrderBy.String()) defer sessQuery.Close() - if opts.Page != 0 { + if opts.Page > 0 { sessQuery = db.SetSessionPagination(sessQuery, opts) } diff --git a/models/user/user.go b/models/user/user.go index a2d91662919..bd92693b6e0 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -330,7 +330,7 @@ func GetUserFollowers(ctx context.Context, u, viewer *User, listOptions db.ListO And("`user`.type=?", UserTypeIndividual). And(isUserVisibleToViewerCond(viewer)) - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) users := make([]*User, 0, listOptions.PageSize) @@ -352,7 +352,7 @@ func GetUserFollowing(ctx context.Context, u, viewer *User, listOptions db.ListO And("`user`.type IN (?, ?)", UserTypeIndividual, UserTypeOrganization). And(isUserVisibleToViewerCond(viewer)) - if listOptions.Page != 0 { + if listOptions.Page > 0 { sess = db.SetSessionPagination(sess, &listOptions) users := make([]*User, 0, listOptions.PageSize) diff --git a/modules/indexer/code/indexer_test.go b/modules/indexer/code/indexer_test.go index 78fbe7f7924..d04088531ac 100644 --- a/modules/indexer/code/indexer_test.go +++ b/modules/indexer/code/indexer_test.go @@ -22,8 +22,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - _ "github.com/mattn/go-sqlite3" ) type codeSearchResult struct { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 23f466873ba..aee76325a86 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -321,7 +321,7 @@ func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeC } if !allow { - ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s): %v", requiredScopes)) + ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s), required=%v, token scope=%v", requiredScopes, scope)) return } @@ -1377,6 +1377,8 @@ func Routes() *web.Router { m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) m.Delete("", repo.DeleteAvatar) }, reqAdmin(), reqToken()) + + m.Get("/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive) }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go index 921e7b2750e..6063e54cdcb 100644 --- a/routers/api/v1/misc/markup_test.go +++ b/routers/api/v1/misc/markup_test.go @@ -7,15 +7,19 @@ import ( go_context "context" "io" "net/http" + "os" "path" "strings" "testing" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/web" + context_service "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" @@ -23,10 +27,17 @@ import ( const AppURL = "http://localhost:3000/" +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + FixtureFiles: []string{"repository.yml", "user.yml"}, + }) + os.Exit(m.Run()) +} + func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) { setting.AppURL = AppURL defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() - context := "/gogits/gogs" + context := "/user2/repo1" if !wiki { context += path.Join("/src/branch/main", path.Dir(filePath)) } @@ -38,6 +49,8 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe FilePath: filePath, } ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup") + ctx.Repo = &context_service.Repository{} + ctx.Repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) web.SetForm(ctx, &options) Markup(ctx) assert.Equal(t, expectedBody, resp.Body.String()) @@ -48,7 +61,7 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) { defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() setting.AppURL = AppURL - context := "/gogits/gogs" + context := "/user2/repo1" if !wiki { context += "/src/branch/main" } @@ -67,6 +80,7 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody } func TestAPI_RenderGFM(t *testing.T) { + unittest.PrepareTestEnv(t) markup.Init(&markup.RenderHelperFuncs{ IsUsernameMentionable: func(ctx go_context.Context, username string) bool { return username == "r-lyeh" @@ -82,20 +96,20 @@ func TestAPI_RenderGFM(t *testing.T) { // rendered `

Wiki! Enjoy :)

`, // Guard wiki sidebar: special syntax `[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`, // rendered - `

Guardfile-DSL / Configuring-Guard

+ `

Guardfile-DSL / Configuring-Guard

`, // special syntax `[[Name|Link]]`, // rendered - `

Name

+ `

Name

`, // empty ``, @@ -119,8 +133,8 @@ Here are some links to the most important topics. You can find the full list of

Wine Staging on website wine-staging.com.

Here are some links to the most important topics. You can find the full list of pages at the sidebar.

-

Configuration -images/icon-bug.png

+

Configuration +images/icon-bug.png

`, } @@ -143,20 +157,20 @@ Here are some links to the most important topics. You can find the full list of } input := "[Link](test.md)\n![Image](image.png)" - testRenderMarkdown(t, "gfm", false, input, `

Link -Image

+ testRenderMarkdown(t, "gfm", false, input, `

Link +Image

`, http.StatusOK) - testRenderMarkdown(t, "gfm", false, input, `

Link -Image

+ testRenderMarkdown(t, "gfm", false, input, `

Link +Image

`, http.StatusOK) - testRenderMarkup(t, "gfm", false, "", input, `

Link -Image

+ testRenderMarkup(t, "gfm", false, "", input, `

Link +Image

`, http.StatusOK) - testRenderMarkup(t, "file", false, "path/new-file.md", input, `

Link -Image

+ testRenderMarkup(t, "file", false, "path/new-file.md", input, `

Link +Image

`, http.StatusOK) testRenderMarkup(t, "file", false, "path/test.unknown", "## Test", "unsupported file to render: \"path/test.unknown\"\n", http.StatusUnprocessableEntity) @@ -186,7 +200,7 @@ func TestAPI_RenderSimple(t *testing.T) { options := api.MarkdownOption{ Mode: "markdown", Text: "", - Context: "/gogits/gogs", + Context: "/user2/repo1", } ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") for i := 0; i < len(simpleCases); i += 2 { diff --git a/routers/api/v1/repo/download.go b/routers/api/v1/repo/download.go new file mode 100644 index 00000000000..3620c1465fe --- /dev/null +++ b/routers/api/v1/repo/download.go @@ -0,0 +1,53 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/services/context" + archiver_service "code.gitea.io/gitea/services/repository/archiver" +) + +func DownloadArchive(ctx *context.APIContext) { + var tp git.ArchiveType + switch ballType := ctx.PathParam("ball_type"); ballType { + case "tarball": + tp = git.TARGZ + case "zipball": + tp = git.ZIP + case "bundle": + tp = git.BUNDLE + default: + ctx.Error(http.StatusBadRequest, "", fmt.Sprintf("Unknown archive type: %s", ballType)) + return + } + + if ctx.Repo.GitRepo == nil { + gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "OpenRepository", err) + return + } + ctx.Repo.GitRepo = gitRepo + defer gitRepo.Close() + } + + r, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, ctx.PathParam("*"), tp) + if err != nil { + ctx.ServerError("NewRequest", err) + return + } + + archive, err := r.Await(ctx) + if err != nil { + ctx.ServerError("archive.Await", err) + return + } + + download(ctx, r.GetArchiveName(), archive) +} diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 05650cc9bed..959a4b952a1 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -301,7 +301,13 @@ func GetArchive(ctx *context.APIContext) { func archiveDownload(ctx *context.APIContext) { uri := ctx.PathParam("*") - aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + ext, tp, err := archiver_service.ParseFileName(uri) + if err != nil { + ctx.Error(http.StatusBadRequest, "ParseFileName", err) + return + } + + aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp) if err != nil { if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { ctx.Error(http.StatusBadRequest, "unknown archive format", err) @@ -327,9 +333,12 @@ func download(ctx *context.APIContext, archiveName string, archiver *repo_model. // Add nix format link header so tarballs lock correctly: // https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md - ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.tar.gz?rev=%s>; rel="immutable"`, + ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.%s?rev=%s>; rel="immutable"`, ctx.Repo.Repository.APIURL(), - archiver.CommitID, archiver.CommitID)) + archiver.CommitID, + archiver.Type.String(), + archiver.CommitID, + )) rPath := archiver.RelativePath() if setting.RepoArchive.Storage.ServeDirect() { diff --git a/routers/common/markup.go b/routers/common/markup.go index e3e6d9cfcf8..533b546a2a1 100644 --- a/routers/common/markup.go +++ b/routers/common/markup.go @@ -77,8 +77,10 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur rctx = rctx.WithMarkupType(markdown.MarkupName) case "comment": rctx = renderhelper.NewRenderContextRepoComment(ctx, repoModel, renderhelper.RepoCommentOptions{DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName}) + rctx = rctx.WithMarkupType(markdown.MarkupName) case "wiki": rctx = renderhelper.NewRenderContextRepoWiki(ctx, repoModel, renderhelper.RepoWikiOptions{DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName}) + rctx = rctx.WithMarkupType(markdown.MarkupName) case "file": rctx = renderhelper.NewRenderContextRepoFile(ctx, repoModel, renderhelper.RepoFileOptions{ DeprecatedOwnerName: repoOwnerName, DeprecatedRepoName: repoName, diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index b62fd215851..f5e59b0357b 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -464,7 +464,12 @@ func RedirectDownload(ctx *context.Context) { // Download an archive of a repository func Download(ctx *context.Context) { uri := ctx.PathParam("*") - aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + ext, tp, err := archiver_service.ParseFileName(uri) + if err != nil { + ctx.ServerError("ParseFileName", err) + return + } + aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp) if err != nil { if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { ctx.Error(http.StatusBadRequest, err.Error()) @@ -523,7 +528,12 @@ func download(ctx *context.Context, archiveName string, archiver *repo_model.Rep // kind of drop it on the floor if this is the case. func InitiateDownload(ctx *context.Context) { uri := ctx.PathParam("*") - aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + ext, tp, err := archiver_service.ParseFileName(uri) + if err != nil { + ctx.ServerError("ParseFileName", err) + return + } + aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp) if err != nil { ctx.ServerError("archiver_service.NewRequest", err) return diff --git a/services/oauth2_provider/access_token.go b/services/oauth2_provider/access_token.go index d94e15d5f25..77ddce0534e 100644 --- a/services/oauth2_provider/access_token.go +++ b/services/oauth2_provider/access_token.go @@ -74,26 +74,32 @@ type AccessTokenResponse struct { // GrantAdditionalScopes returns valid scopes coming from grant func GrantAdditionalScopes(grantScopes string) auth.AccessTokenScope { // scopes_supported from templates/user/auth/oidc_wellknown.tmpl - scopesSupported := []string{ + generalScopesSupported := []string{ "openid", "profile", "email", "groups", } - var tokenScopes []string - for _, tokenScope := range strings.Split(grantScopes, " ") { - if slices.Index(scopesSupported, tokenScope) == -1 { - tokenScopes = append(tokenScopes, tokenScope) + var accessScopes []string // the scopes for access control, but not for general information + for _, scope := range strings.Split(grantScopes, " ") { + if scope != "" && !slices.Contains(generalScopesSupported, scope) { + accessScopes = append(accessScopes, scope) } } // since version 1.22, access tokens grant full access to the API // with this access is reduced only if additional scopes are provided - accessTokenScope := auth.AccessTokenScope(strings.Join(tokenScopes, ",")) - if accessTokenWithAdditionalScopes, err := accessTokenScope.Normalize(); err == nil && len(tokenScopes) > 0 { - return accessTokenWithAdditionalScopes + if len(accessScopes) > 0 { + accessTokenScope := auth.AccessTokenScope(strings.Join(accessScopes, ",")) + if normalizedAccessTokenScope, err := accessTokenScope.Normalize(); err == nil { + return normalizedAccessTokenScope + } + // TODO: if there are invalid access scopes (err != nil), + // then it is treated as "all", maybe in the future we should make it stricter to return an error + // at the moment, to avoid breaking 1.22 behavior, invalid tokens are also treated as "all" } + // fallback, empty access scope is treated as "all" access return auth.AccessTokenScopeAll } diff --git a/services/oauth2_provider/additional_scopes_test.go b/services/oauth2_provider/additional_scopes_test.go index d239229f4be..2d4df7aea2b 100644 --- a/services/oauth2_provider/additional_scopes_test.go +++ b/services/oauth2_provider/additional_scopes_test.go @@ -14,6 +14,7 @@ func TestGrantAdditionalScopes(t *testing.T) { grantScopes string expectedScopes string }{ + {"", "all"}, // for old tokens without scope, treat it as "all" {"openid profile email", "all"}, {"openid profile email groups", "all"}, {"openid profile email all", "all"}, @@ -22,12 +23,14 @@ func TestGrantAdditionalScopes(t *testing.T) { {"read:user read:repository", "read:repository,read:user"}, {"read:user write:issue public-only", "public-only,write:issue,read:user"}, {"openid profile email read:user", "read:user"}, + + // TODO: at the moment invalid tokens are treated as "all" to avoid breaking 1.22 behavior (more details are in GrantAdditionalScopes) {"read:invalid_scope", "all"}, {"read:invalid_scope,write:scope_invalid,just-plain-wrong", "all"}, } for _, test := range tests { - t.Run(test.grantScopes, func(t *testing.T) { + t.Run("scope:"+test.grantScopes, func(t *testing.T) { result := GrantAdditionalScopes(test.grantScopes) assert.Equal(t, test.expectedScopes, string(result)) }) diff --git a/services/repository/archiver/archiver.go b/services/repository/archiver/archiver.go index c33369d047e..e1addbed335 100644 --- a/services/repository/archiver/archiver.go +++ b/services/repository/archiver/archiver.go @@ -67,30 +67,36 @@ func (e RepoRefNotFoundError) Is(err error) bool { return ok } -// NewRequest creates an archival request, based on the URI. The -// resulting ArchiveRequest is suitable for being passed to Await() -// if it's determined that the request still needs to be satisfied. -func NewRequest(repoID int64, repo *git.Repository, uri string) (*ArchiveRequest, error) { - r := &ArchiveRequest{ - RepoID: repoID, - } - - var ext string +func ParseFileName(uri string) (ext string, tp git.ArchiveType, err error) { switch { case strings.HasSuffix(uri, ".zip"): ext = ".zip" - r.Type = git.ZIP + tp = git.ZIP case strings.HasSuffix(uri, ".tar.gz"): ext = ".tar.gz" - r.Type = git.TARGZ + tp = git.TARGZ case strings.HasSuffix(uri, ".bundle"): ext = ".bundle" - r.Type = git.BUNDLE + tp = git.BUNDLE default: - return nil, ErrUnknownArchiveFormat{RequestFormat: uri} + return "", 0, ErrUnknownArchiveFormat{RequestFormat: uri} + } + return ext, tp, nil +} + +// NewRequest creates an archival request, based on the URI. The +// resulting ArchiveRequest is suitable for being passed to Await() +// if it's determined that the request still needs to be satisfied. +func NewRequest(repoID int64, repo *git.Repository, refName string, fileType git.ArchiveType) (*ArchiveRequest, error) { + if fileType < git.ZIP || fileType > git.BUNDLE { + return nil, ErrUnknownArchiveFormat{RequestFormat: fileType.String()} } - r.refName = strings.TrimSuffix(uri, ext) + r := &ArchiveRequest{ + RepoID: repoID, + refName: refName, + Type: fileType, + } // Get corresponding commit. commitID, err := repo.ConvertToGitID(r.refName) diff --git a/services/repository/archiver/archiver_test.go b/services/repository/archiver/archiver_test.go index b3f3ed7bf3e..2ab18edf491 100644 --- a/services/repository/archiver/archiver_test.go +++ b/services/repository/archiver/archiver_test.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/services/contexttest" _ "code.gitea.io/gitea/models/actions" @@ -31,47 +32,47 @@ func TestArchive_Basic(t *testing.T) { contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() - bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") + bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP) assert.NoError(t, err) assert.NotNil(t, bogusReq) assert.EqualValues(t, firstCommit+".zip", bogusReq.GetArchiveName()) // Check a series of bogus requests. // Step 1, valid commit with a bad extension. - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".dilbert") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, 100) assert.Error(t, err) assert.Nil(t, bogusReq) // Step 2, missing commit. - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff.zip") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff", git.ZIP) assert.Error(t, err) assert.Nil(t, bogusReq) // Step 3, doesn't look like branch/tag/commit. - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db.zip") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db", git.ZIP) assert.Error(t, err) assert.Nil(t, bogusReq) - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master.zip") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master", git.ZIP) assert.NoError(t, err) assert.NotNil(t, bogusReq) assert.EqualValues(t, "master.zip", bogusReq.GetArchiveName()) - bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive.zip") + bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive", git.ZIP) assert.NoError(t, err) assert.NotNil(t, bogusReq) assert.EqualValues(t, "test-archive.zip", bogusReq.GetArchiveName()) // Now two valid requests, firstCommit with valid extensions. - zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") + zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP) assert.NoError(t, err) assert.NotNil(t, zipReq) - tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".tar.gz") + tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.TARGZ) assert.NoError(t, err) assert.NotNil(t, tgzReq) - secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".zip") + secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.ZIP) assert.NoError(t, err) assert.NotNil(t, secondReq) @@ -91,7 +92,7 @@ func TestArchive_Basic(t *testing.T) { // Sleep two seconds to make sure the queue doesn't change. time.Sleep(2 * time.Second) - zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") + zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP) assert.NoError(t, err) // This zipReq should match what's sitting in the queue, as we haven't // let it release yet. From the consumer's point of view, this looks like @@ -106,12 +107,12 @@ func TestArchive_Basic(t *testing.T) { // Now we'll submit a request and TimedWaitForCompletion twice, before and // after we release it. We should trigger both the timeout and non-timeout // cases. - timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".tar.gz") + timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.TARGZ) assert.NoError(t, err) assert.NotNil(t, timedReq) doArchive(db.DefaultContext, timedReq) - zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") + zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP) assert.NoError(t, err) // Now, we're guaranteed to have released the original zipReq from the queue. // Ensure that we don't get handed back the released entry somehow, but they diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 26737f110e4..20e0c9db668 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -164,24 +164,22 @@ {{ctx.Locale.Tr "repo.pulls.has_viewed_file"}} {{end}} - diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 5b9f16ef96d..9f75478ebfc 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -144,6 +144,18 @@ func TestAPICreateIssue(t *testing.T) { func TestAPICreateIssueParallel(t *testing.T) { defer tests.PrepareTestEnv(t)() + + // FIXME: There seems to be a bug in github.com/mattn/go-sqlite3 with sqlite_unlock_notify, when doing concurrent writes to the same database, + // some requests may get stuck in "go-sqlite3.(*SQLiteRows).Next", "go-sqlite3.(*SQLiteStmt).exec" and "go-sqlite3.unlock_notify_wait", + // because the "unlock_notify_wait" never returns and the internal lock never gets releases. + // + // The trigger is: a previous test created issues and made the real issue indexer queue start processing, then this test does concurrent writing. + // Adding this "Sleep" makes go-sqlite3 "finish" some internal operations before concurrent writes and then won't get stuck. + // To reproduce: make a new test run these 2 tests enough times: + // > func TestBug() { for i := 0; i < 100; i++ { testAPICreateIssue(t); testAPICreateIssueParallel(t) } } + // Usually the test gets stuck in fewer than 10 iterations without this "sleep". + time.Sleep(time.Second) + const body, title = "apiTestBody", "apiTestTitle" repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) diff --git a/tests/integration/api_repo_archive_test.go b/tests/integration/api_repo_archive_test.go index eecb84d5d1b..8589199da39 100644 --- a/tests/integration/api_repo_archive_test.go +++ b/tests/integration/api_repo_archive_test.go @@ -59,3 +59,43 @@ func TestAPIDownloadArchive(t *testing.T) { link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name)) MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest) } + +func TestAPIDownloadArchive2(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/zipball/master", user2.Name, repo.Name)) + resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Len(t, bs, 320) + + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/tarball/master", user2.Name, repo.Name)) + resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err = io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Len(t, bs, 266) + + // Must return a link to a commit ID as the "immutable" archive link + linkHeaderRe := regexp.MustCompile(`^<(https?://.*/api/v1/repos/user2/repo1/archive/[a-f0-9]+\.tar\.gz.*)>; rel="immutable"$`) + m := linkHeaderRe.FindStringSubmatch(resp.Header().Get("Link")) + assert.NotEmpty(t, m[1]) + resp = MakeRequest(t, NewRequest(t, "GET", m[1]).AddTokenAuth(token), http.StatusOK) + bs2, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + // The locked URL should give the same bytes as the non-locked one + assert.EqualValues(t, bs, bs2) + + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/bundle/master", user2.Name, repo.Name)) + resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err = io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Len(t, bs, 382) + + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name)) + MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest) +} diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index feb262b50e2..f177bd3a23b 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -565,7 +565,7 @@ func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) { errorParsed := new(errorResponse) require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed)) - assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:repository]") + assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s), required=[read:repository]") } func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) { @@ -708,7 +708,7 @@ func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) { errorParsed := new(errorResponse) require.NoError(t, json.Unmarshal(errorResp.Body.Bytes(), errorParsed)) - assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s): [read:user read:organization]") + assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s), required=[read:user read:organization]") } func TestOAuth_GrantScopesClaimPublicOnlyGroups(t *testing.T) { diff --git a/tests/integration/pull_compare_test.go b/tests/integration/pull_compare_test.go index def6506253f..ad0be72dcbd 100644 --- a/tests/integration/pull_compare_test.go +++ b/tests/integration/pull_compare_test.go @@ -97,7 +97,7 @@ func TestPullCompare_EnableAllowEditsFromMaintainer(t *testing.T) { user2Session := loginUser(t, "user2") resp = user2Session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("%s/files", prURL)), http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - nodes := htmlDoc.doc.Find(".diff-file-box[data-new-filename=\"README.md\"] .diff-file-header-actions .dropdown .menu a") + nodes := htmlDoc.doc.Find(".diff-file-box[data-new-filename=\"README.md\"] .diff-file-header-actions .tippy-target a") if assert.Equal(t, 1, nodes.Length()) { // there is only "View File" button, no "Edit File" button assert.Equal(t, "View File", nodes.First().Text()) @@ -121,7 +121,7 @@ func TestPullCompare_EnableAllowEditsFromMaintainer(t *testing.T) { // user2 (admin of repo3) goes to the PR files page again resp = user2Session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("%s/files", prURL)), http.StatusOK) htmlDoc = NewHTMLParser(t, resp.Body) - nodes = htmlDoc.doc.Find(".diff-file-box[data-new-filename=\"README.md\"] .diff-file-header-actions .dropdown .menu a") + nodes = htmlDoc.doc.Find(".diff-file-box[data-new-filename=\"README.md\"] .diff-file-header-actions .tippy-target a") if assert.Equal(t, 2, nodes.Length()) { // there are "View File" button and "Edit File" button assert.Equal(t, "View File", nodes.First().Text()) diff --git a/web_src/css/modules/tippy.css b/web_src/css/modules/tippy.css index 53c3d5aaeac..55b9751cc63 100644 --- a/web_src/css/modules/tippy.css +++ b/web_src/css/modules/tippy.css @@ -77,8 +77,10 @@ align-items: center; padding: 9px 18px; color: inherit; + background: inherit; text-decoration: none; gap: 10px; + width: 100%; } .tippy-box[data-theme="menu"] .item:hover { diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index 0d489665a23..f39de96f5be 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -18,6 +18,7 @@ import { } from '../utils/dom.ts'; import {POST, GET} from '../modules/fetch.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {createTippy} from '../modules/tippy.ts'; const {pageData, i18n} = window.config; @@ -140,12 +141,22 @@ export function initRepoDiffConversationNav() { }); } +function initDiffHeaderPopup() { + for (const btn of document.querySelectorAll('.diff-header-popup-btn:not([data-header-popup-initialized])')) { + btn.setAttribute('data-header-popup-initialized', ''); + const popup = btn.nextElementSibling; + if (!popup?.matches('.tippy-target')) throw new Error('Popup element not found'); + createTippy(btn, {content: popup, theme: 'menu', placement: 'bottom', trigger: 'click', interactive: true, hideOnClick: true}); + } +} + // Will be called when the show more (files) button has been pressed function onShowMoreFiles() { initRepoIssueContentHistory(); initViewedCheckboxListenerFor(); countAndUpdateViewedFiles(); initImageDiff(); + initDiffHeaderPopup(); } export async function loadMoreFiles(url) { @@ -221,6 +232,7 @@ export function initRepoDiffView() { initDiffFileList(); initDiffCommitSelect(); initRepoDiffShowMore(); + initDiffHeaderPopup(); initRepoDiffFileViewToggle(); initViewedCheckboxListenerFor(); initExpandAndCollapseFilesButton(); diff --git a/web_src/js/features/repo-unicode-escape.ts b/web_src/js/features/repo-unicode-escape.ts index 7a9bca7a376..0c7d2e8592a 100644 --- a/web_src/js/features/repo-unicode-escape.ts +++ b/web_src/js/features/repo-unicode-escape.ts @@ -1,13 +1,13 @@ -import {hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.ts'; +import {addDelegatedEventListener, hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.ts'; export function initUnicodeEscapeButton() { - document.addEventListener('click', (e) => { - const btn = e.target.closest('.escape-button, .unescape-button, .toggle-escape-button'); - if (!btn) return; - + addDelegatedEventListener(document, 'click', '.escape-button, .unescape-button, .toggle-escape-button', (btn, e) => { e.preventDefault(); - const fileContent = btn.closest('.file-content, .non-diff-file-content'); + const fileContentElemId = btn.getAttribute('data-file-content-elem-id'); + const fileContent = fileContentElemId ? + document.querySelector(`#${fileContentElemId}`) : + btn.closest('.file-content, .non-diff-file-content'); const fileView = fileContent?.querySelectorAll('.file-code, .file-view'); if (btn.matches('.escape-button')) { for (const el of fileView) el.classList.add('unicode-escaped'); diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index 004795d3679..2dbed280c2a 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -58,16 +58,12 @@ export async function renderMermaid(): Promise { mermaidBlock.append(btn); const updateIframeHeight = () => { - iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`; + const body = iframe.contentWindow?.document?.body; + if (body) { + iframe.style.height = `${body.clientHeight}px`; + } }; - // update height when element's visibility state changes, for example when the diagram is inside - // a
+ block and the
block becomes visible upon user interaction, it - // would initially set a incorrect height and the correct height is set during this callback. - (new IntersectionObserver(() => { - updateIframeHeight(); - }, {root: document.documentElement})).observe(iframe); - iframe.addEventListener('load', () => { pre.replaceWith(mermaidBlock); mermaidBlock.classList.remove('tw-hidden'); @@ -76,6 +72,13 @@ export async function renderMermaid(): Promise { mermaidBlock.classList.remove('is-loading'); iframe.classList.remove('tw-invisible'); }, 0); + + // update height when element's visibility state changes, for example when the diagram is inside + // a
+ block and the
block becomes visible upon user interaction, it + // would initially set a incorrect height and the correct height is set during this callback. + (new IntersectionObserver(() => { + updateIframeHeight(); + }, {root: document.documentElement})).observe(iframe); }); document.body.append(mermaidBlock); diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts index cb99a85511b..6e715968507 100644 --- a/web_src/js/utils/dom.test.ts +++ b/web_src/js/utils/dom.test.ts @@ -1,4 +1,4 @@ -import {createElementFromAttrs, createElementFromHTML, querySingleVisibleElem} from './dom.ts'; +import {createElementFromAttrs, createElementFromHTML, queryElemChildren, querySingleVisibleElem} from './dom.ts'; test('createElementFromHTML', () => { expect(createElementFromHTML('foobar').outerHTML).toEqual('foobar'); @@ -26,3 +26,9 @@ test('querySingleVisibleElem', () => { el = createElementFromHTML('
foobar
'); expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element'); }); + +test('queryElemChildren', () => { + const el = createElementFromHTML('
ab
'); + const children = queryElemChildren(el, '.a'); + expect(children.length).toEqual(1); +}); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 4bbb0c414ac..da9ce71644c 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -2,10 +2,10 @@ import {debounce} from 'throttle-debounce'; import type {Promisable} from 'type-fest'; import type $ from 'jquery'; -type ElementArg = Element | string | NodeListOf | Array | ReturnType; +type ArrayLikeIterable = ArrayLike & Iterable; // for NodeListOf and Array +type ElementArg = Element | string | ArrayLikeIterable | ReturnType; type ElementsCallback = (el: T) => Promisable; type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable; -type ArrayLikeIterable = ArrayLike & Iterable; // for NodeListOf and Array function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) { if (typeof el === 'string' || el instanceof String) { @@ -76,6 +76,11 @@ export function queryElemSiblings(el: Element, selector = '*' // it works like jQuery.children: only the direct children are selected export function queryElemChildren(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback): ArrayLikeIterable { + if (window.vitest) { + // bypass the vitest bug: it doesn't support ":scope >" + const selected = Array.from(parent.children as any).filter((child) => child.matches(selector)); + return applyElemsCallback(selected, fn); + } return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn); }