diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index e619aae729..9196180d81 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -81,6 +81,10 @@ RUN_USER = ; git ;; Overwrite the automatically generated public URL. Necessary for proxies and docker. ;ROOT_URL = %(PROTOCOL)s://%(DOMAIN)s:%(HTTP_PORT)s/ ;; +;; For development purpose only. It makes Gitea handle sub-path ("/sub-path/owner/repo/...") directly when debugging without a reverse proxy. +;; DO NOT USE IT IN PRODUCTION!!! +;USE_SUB_URL_PATH = false +;; ;; when STATIC_URL_PREFIX is empty it will follow ROOT_URL ;STATIC_URL_PREFIX = ;; diff --git a/models/git/protected_tag.go b/models/git/protected_tag.go index 8a05045651..9a6646c742 100644 --- a/models/git/protected_tag.go +++ b/models/git/protected_tag.go @@ -110,6 +110,19 @@ func GetProtectedTagByID(ctx context.Context, id int64) (*ProtectedTag, error) { return tag, nil } +// GetProtectedTagByNamePattern gets protected tag by name_pattern +func GetProtectedTagByNamePattern(ctx context.Context, repoID int64, pattern string) (*ProtectedTag, error) { + tag := &ProtectedTag{NamePattern: pattern, RepoID: repoID} + has, err := db.GetEngine(ctx).Get(tag) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return tag, nil +} + // IsUserAllowedToControlTag checks if a user can control the specific tag. // It returns true if the tag name is not protected or the user is allowed to control it. func IsUserAllowedToControlTag(ctx context.Context, tags []*ProtectedTag, tagName string, userID int64) (bool, error) { diff --git a/models/repo/avatar_test.go b/models/repo/avatar_test.go new file mode 100644 index 0000000000..fc1f8baeca --- /dev/null +++ b/models/repo/avatar_test.go @@ -0,0 +1,28 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestRepoAvatarLink(t *testing.T) { + defer test.MockVariableValue(&setting.AppURL, "https://localhost/")() + defer test.MockVariableValue(&setting.AppSubURL, "")() + + repo := &Repository{ID: 1, Avatar: "avatar.png"} + link := repo.AvatarLink(db.DefaultContext) + assert.Equal(t, "https://localhost/repo-avatars/avatar.png", link) + + setting.AppURL = "https://localhost/sub-path/" + setting.AppSubURL = "/sub-path" + link = repo.AvatarLink(db.DefaultContext) + assert.Equal(t, "https://localhost/sub-path/repo-avatars/avatar.png", link) +} diff --git a/models/repo/search.go b/models/repo/search.go index c500d41be8..a73d9fc215 100644 --- a/models/repo/search.go +++ b/models/repo/search.go @@ -5,72 +5,48 @@ package repo import "code.gitea.io/gitea/models/db" -// Strings for sorting result -const ( - // only used for repos - SearchOrderByAlphabetically db.SearchOrderBy = "owner_name ASC, name ASC" - SearchOrderByAlphabeticallyReverse db.SearchOrderBy = "owner_name DESC, name DESC" - SearchOrderBySize db.SearchOrderBy = "size ASC" - SearchOrderBySizeReverse db.SearchOrderBy = "size DESC" - SearchOrderByGitSize db.SearchOrderBy = "git_size ASC" - SearchOrderByGitSizeReverse db.SearchOrderBy = "git_size DESC" - SearchOrderByLFSSize db.SearchOrderBy = "lfs_size ASC" - SearchOrderByLFSSizeReverse db.SearchOrderBy = "lfs_size DESC" - // alias as also used elsewhere - SearchOrderByLeastUpdated db.SearchOrderBy = db.SearchOrderByLeastUpdated - SearchOrderByRecentUpdated db.SearchOrderBy = db.SearchOrderByRecentUpdated - SearchOrderByOldest db.SearchOrderBy = db.SearchOrderByOldest - SearchOrderByNewest db.SearchOrderBy = db.SearchOrderByNewest - SearchOrderByID db.SearchOrderBy = db.SearchOrderByID - SearchOrderByIDReverse db.SearchOrderBy = db.SearchOrderByIDReverse - SearchOrderByStars db.SearchOrderBy = db.SearchOrderByStars - SearchOrderByStarsReverse db.SearchOrderBy = db.SearchOrderByStarsReverse - SearchOrderByForks db.SearchOrderBy = db.SearchOrderByForks - SearchOrderByForksReverse db.SearchOrderBy = db.SearchOrderByForksReverse -) - -// SearchOrderByMap represents all possible search order -var SearchOrderByMap = map[string]map[string]db.SearchOrderBy{ +// OrderByMap represents all possible search order +var OrderByMap = map[string]map[string]db.SearchOrderBy{ "asc": { - "alpha": SearchOrderByAlphabetically, - "created": SearchOrderByOldest, - "updated": SearchOrderByLeastUpdated, - "size": SearchOrderBySize, - "git_size": SearchOrderByGitSize, - "lfs_size": SearchOrderByLFSSize, - "id": SearchOrderByID, - "stars": SearchOrderByStars, - "forks": SearchOrderByForks, + "alpha": "owner_name ASC, name ASC", + "created": db.SearchOrderByOldest, + "updated": db.SearchOrderByLeastUpdated, + "size": "size ASC", + "git_size": "git_size ASC", + "lfs_size": "lfs_size ASC", + "id": db.SearchOrderByID, + "stars": db.SearchOrderByStars, + "forks": db.SearchOrderByForks, }, "desc": { - "alpha": SearchOrderByAlphabeticallyReverse, - "created": SearchOrderByNewest, - "updated": SearchOrderByRecentUpdated, - "size": SearchOrderBySizeReverse, - "git_size": SearchOrderByGitSizeReverse, - "lfs_size": SearchOrderByLFSSizeReverse, - "id": SearchOrderByIDReverse, - "stars": SearchOrderByStarsReverse, - "forks": SearchOrderByForksReverse, + "alpha": "owner_name DESC, name DESC", + "created": db.SearchOrderByNewest, + "updated": db.SearchOrderByRecentUpdated, + "size": "size DESC", + "git_size": "git_size DESC", + "lfs_size": "lfs_size DESC", + "id": db.SearchOrderByIDReverse, + "stars": db.SearchOrderByStarsReverse, + "forks": db.SearchOrderByForksReverse, }, } -// SearchOrderByFlatMap is similar to SearchOrderByMap but use human language keywords +// OrderByFlatMap is similar to OrderByMap but use human language keywords // to decide between asc and desc -var SearchOrderByFlatMap = map[string]db.SearchOrderBy{ - "newest": SearchOrderByMap["desc"]["created"], - "oldest": SearchOrderByMap["asc"]["created"], - "leastupdate": SearchOrderByMap["asc"]["updated"], - "reversealphabetically": SearchOrderByMap["desc"]["alpha"], - "alphabetically": SearchOrderByMap["asc"]["alpha"], - "reversesize": SearchOrderByMap["desc"]["size"], - "size": SearchOrderByMap["asc"]["size"], - "reversegitsize": SearchOrderByMap["desc"]["git_size"], - "gitsize": SearchOrderByMap["asc"]["git_size"], - "reverselfssize": SearchOrderByMap["desc"]["lfs_size"], - "lfssize": SearchOrderByMap["asc"]["lfs_size"], - "moststars": SearchOrderByMap["desc"]["stars"], - "feweststars": SearchOrderByMap["asc"]["stars"], - "mostforks": SearchOrderByMap["desc"]["forks"], - "fewestforks": SearchOrderByMap["asc"]["forks"], +var OrderByFlatMap = map[string]db.SearchOrderBy{ + "newest": OrderByMap["desc"]["created"], + "oldest": OrderByMap["asc"]["created"], + "leastupdate": OrderByMap["asc"]["updated"], + "reversealphabetically": OrderByMap["desc"]["alpha"], + "alphabetically": OrderByMap["asc"]["alpha"], + "reversesize": OrderByMap["desc"]["size"], + "size": OrderByMap["asc"]["size"], + "reversegitsize": OrderByMap["desc"]["git_size"], + "gitsize": OrderByMap["asc"]["git_size"], + "reverselfssize": OrderByMap["desc"]["lfs_size"], + "lfssize": OrderByMap["asc"]["lfs_size"], + "moststars": OrderByMap["desc"]["stars"], + "feweststars": OrderByMap["asc"]["stars"], + "mostforks": OrderByMap["desc"]["forks"], + "fewestforks": OrderByMap["asc"]["forks"], } diff --git a/models/user/avatar.go b/models/user/avatar.go index 921bc1b1a1..5453c78fc6 100644 --- a/models/user/avatar.go +++ b/models/user/avatar.go @@ -89,9 +89,11 @@ func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string { return avatars.GenerateEmailAvatarFastLink(ctx, u.AvatarEmail, size) } -// AvatarLink returns the full avatar url with http host. TODO: refactor it to a relative URL, but it is still used in API response at the moment +// AvatarLink returns the full avatar url with http host. +// TODO: refactor it to a relative URL, but it is still used in API response at the moment func (u *User) AvatarLink(ctx context.Context) string { - return httplib.MakeAbsoluteURL(ctx, u.AvatarLinkWithSize(ctx, 0)) + relLink := u.AvatarLinkWithSize(ctx, 0) // it can't be empty + return httplib.MakeAbsoluteURL(ctx, relLink) } // IsUploadAvatarChanged returns true if the current user's avatar would be changed with the provided data diff --git a/models/user/avatar_test.go b/models/user/avatar_test.go new file mode 100644 index 0000000000..1078875ee1 --- /dev/null +++ b/models/user/avatar_test.go @@ -0,0 +1,28 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestUserAvatarLink(t *testing.T) { + defer test.MockVariableValue(&setting.AppURL, "https://localhost/")() + defer test.MockVariableValue(&setting.AppSubURL, "")() + + u := &User{ID: 1, Avatar: "avatar.png"} + link := u.AvatarLink(db.DefaultContext) + assert.Equal(t, "https://localhost/avatars/avatar.png", link) + + setting.AppURL = "https://localhost/sub-path/" + setting.AppSubURL = "/sub-path" + link = u.AvatarLink(db.DefaultContext) + assert.Equal(t, "https://localhost/sub-path/avatars/avatar.png", link) +} diff --git a/modules/base/natural_sort.go b/modules/base/natural_sort.go index 0f90ec70ce..acb9002276 100644 --- a/modules/base/natural_sort.go +++ b/modules/base/natural_sort.go @@ -4,12 +4,67 @@ package base import ( + "unicode/utf8" + "golang.org/x/text/collate" "golang.org/x/text/language" ) +func naturalSortGetRune(str string, pos int) (r rune, size int, has bool) { + if pos >= len(str) { + return 0, 0, false + } + r, size = utf8.DecodeRuneInString(str[pos:]) + if r == utf8.RuneError { + r, size = rune(str[pos]), 1 // if invalid input, treat it as a single byte ascii + } + return r, size, true +} + +func naturalSortAdvance(str string, pos int) (end int, isNumber bool) { + end = pos + for { + r, size, has := naturalSortGetRune(str, end) + if !has { + break + } + isCurRuneNum := '0' <= r && r <= '9' + if end == pos { + isNumber = isCurRuneNum + end += size + } else if isCurRuneNum == isNumber { + end += size + } else { + break + } + } + return end, isNumber +} + // NaturalSortLess compares two strings so that they could be sorted in natural order func NaturalSortLess(s1, s2 string) bool { + // There is a bug in Golang's collate package: https://github.com/golang/go/issues/67997 + // text/collate: CompareString(collate.Numeric) returns wrong result for "0.0" vs "1.0" #67997 + // So we need to handle the number parts by ourselves c := collate.New(language.English, collate.Numeric) - return c.CompareString(s1, s2) < 0 + pos1, pos2 := 0, 0 + for pos1 < len(s1) && pos2 < len(s2) { + end1, isNum1 := naturalSortAdvance(s1, pos1) + end2, isNum2 := naturalSortAdvance(s2, pos2) + part1, part2 := s1[pos1:end1], s2[pos2:end2] + if isNum1 && isNum2 { + if part1 != part2 { + if len(part1) != len(part2) { + return len(part1) < len(part2) + } + return part1 < part2 + } + } else { + if cmp := c.CompareString(part1, part2); cmp != 0 { + return cmp < 0 + } + } + pos1, pos2 = end1, end2 + } + return len(s1) < len(s2) } diff --git a/modules/base/natural_sort_test.go b/modules/base/natural_sort_test.go index f27a4eb53a..b001bc4ac9 100644 --- a/modules/base/natural_sort_test.go +++ b/modules/base/natural_sort_test.go @@ -10,21 +10,36 @@ import ( ) func TestNaturalSortLess(t *testing.T) { - test := func(s1, s2 string, less bool) { - assert.Equal(t, less, NaturalSortLess(s1, s2), "s1=%q, s2=%q", s1, s2) + testLess := func(s1, s2 string) { + assert.True(t, NaturalSortLess(s1, s2), "s1Klikněte zde, abyste je zobrazili, nebo potvrďte změny ještě jednou pro jejich přepsání. editor.file_already_exists=Soubor „%s“ již existuje v tomto repozitáři. editor.commit_id_not_matching=ID commitu se neshoduje s ID, když jsi začal/a s úpravami. Odevzdat do záplatové větve a poté sloučit. +editor.push_out_of_date=Nahrání se zdá být zastaralé. editor.commit_empty_file_header=Odevzdat prázdný soubor editor.commit_empty_file_text=Soubor, který se chystáte odevzdat, je prázdný. Pokračovat? editor.no_changes_to_show=Žádné změny k zobrazení. @@ -2080,6 +2082,7 @@ settings.mirror_settings.docs.doc_link_title=Jak mohu zrcadlit repozitáře? settings.mirror_settings.docs.doc_link_pull_section=sekci "stahovat ze vzdáleného úložiště" v dokumentaci. settings.mirror_settings.docs.pulling_remote_title=Stažení ze vzdáleného úložiště settings.mirror_settings.mirrored_repository=Zrcadlený repozitář +settings.mirror_settings.pushed_repository=Odeslaný repozitář settings.mirror_settings.direction=Směr settings.mirror_settings.direction.pull=Natáhnout settings.mirror_settings.direction.push=Nahrát @@ -3284,6 +3287,7 @@ monitor.queue.name=Název monitor.queue.type=Typ monitor.queue.exemplar=Typ vzoru monitor.queue.numberworkers=Počet workerů +monitor.queue.activeworkers=Aktivní workery monitor.queue.maxnumberworkers=Maximální počet workerů monitor.queue.numberinqueue=Číslo ve frontě monitor.queue.review_add=Posoudit / přidat workery @@ -3593,6 +3597,8 @@ status.cancelled=Zrušeno status.skipped=Přeskočeno status.blocked=Blokováno +runners=Runnery +runners.runner_manage_panel=Správa runnerů runners.new=Vytvořit nový runner runners.new_notice=Jak spustit runner runners.status=Status @@ -3619,6 +3625,7 @@ runners.delete_runner_success=Runner byl úspěšně odstraněn runners.delete_runner_failed=Odstranění runneru selhalo runners.delete_runner_header=Potvrdit odstranění tohoto runneru runners.delete_runner_notice=Pokud na tomto runneru běží úloha, bude ukončena a označena jako neúspěšná. Může dojít k přerušení vytváření pracovního postupu. +runners.none=Žádné runnery nejsou k dispozici runners.status.unspecified=Neznámý runners.status.idle=Nečinný runners.status.active=Aktivní diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini index d85ffb4694..acce73b0af 100644 --- a/options/locale/locale_ja-JP.ini +++ b/options/locale/locale_ja-JP.ini @@ -1238,6 +1238,7 @@ file_view_rendered=レンダリング表示 file_view_raw=Rawデータを見る file_permalink=パーマリンク file_too_large=このファイルは大きすぎるため、表示できません。 +file_is_empty=ファイルは空です。 code_preview_line_from_to=%[1]d 行目から %[2]d 行目 in %[3]s code_preview_line_in=%[1]d 行目 in %[2]s invisible_runes_header=このファイルには不可視のUnicode文字が含まれています @@ -1442,6 +1443,7 @@ issues.new.clear_assignees=担当者をクリア issues.new.no_assignees=担当者なし issues.new.no_reviewers=レビューアなし issues.new.blocked_user=リポジトリのオーナーがあなたをブロックしているため、イシューを作成できません。 +issues.edit.already_changed=イシューの変更を保存できません。 他のユーザーによって内容がすでに変更されているようです。 変更を上書きしないようにするため、ページを更新してからもう一度編集してください issues.edit.blocked_user=投稿者またはリポジトリのオーナーがあなたをブロックしているため、内容を編集できません。 issues.choose.get_started=始める issues.choose.open_external_link=オープン @@ -1757,6 +1759,7 @@ compare.compare_head=比較 pulls.desc=プルリクエストとコードレビューの有効化。 pulls.new=新しいプルリクエスト pulls.new.blocked_user=リポジトリのオーナーがあなたをブロックしているため、プルリクエストを作成できません。 +pulls.edit.already_changed=プルリクエストの変更を保存できません。 他のユーザーによって内容がすでに変更されているようです。 変更を上書きしないようにするため、ページを更新してからもう一度編集してください pulls.view=プルリクエストを表示 pulls.compare_changes=新規プルリクエスト pulls.allow_edits_from_maintainers=メンテナーからの編集を許可する @@ -1902,6 +1905,7 @@ pulls.recently_pushed_new_branches=%[2]s 、あなたはブランチ %[1 pull.deleted_branch=(削除済み):%s +comments.edit.already_changed=コメントの変更を保存できません。 他のユーザーによって内容がすでに変更されているようです。 変更を上書きしないようにするため、ページを更新してからもう一度編集してください milestones.new=新しいマイルストーン milestones.closed=%s にクローズ diff --git a/options/locale/locale_pt-PT.ini b/options/locale/locale_pt-PT.ini index f444cf6072..bf7bd492f8 100644 --- a/options/locale/locale_pt-PT.ini +++ b/options/locale/locale_pt-PT.ini @@ -1238,6 +1238,7 @@ file_view_rendered=Ver resultado processado file_view_raw=Ver em bruto file_permalink=Ligação permanente file_too_large=O ficheiro é demasiado grande para ser apresentado. +file_is_empty=O ficheiro está vazio. code_preview_line_from_to=Linhas %[1]d até %[2]d em %[3]s code_preview_line_in=Linha %[1]d em %[2]s invisible_runes_header=`Este ficheiro contém caracteres Unicode invisíveis` @@ -1554,7 +1555,9 @@ issues.no_content=Nenhuma descrição fornecida. issues.close=Encerrar questão issues.comment_pull_merged_at=cometimento %[1]s integrado em %[2]s %[3]s issues.comment_manually_pull_merged_at=cometimento %[1]s integrado manualmente em %[2]s %[3]s +issues.close_comment_issue=Fechar com comentário issues.reopen_issue=Reabrir +issues.reopen_comment_issue=Reabrir com comentário issues.create_comment=Comentar issues.comment.blocked_user=Não pode criar ou editar o comentário porque foi bloqueado/a pelo remetente ou pelo/a proprietário/a do repositório. issues.closed_at=`encerrou esta questão %[2]s` diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 5e3cbac8f9..4122f632ff 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -588,6 +588,8 @@ func CommonRoutes() *web.Route { r.Get("/prerelease_specs.4.8.gz", rubygems.EnumeratePackagesPreRelease) r.Get("/quick/Marshal.4.8/{filename}", rubygems.ServePackageSpecification) r.Get("/gems/{filename}", rubygems.DownloadPackageFile) + r.Get("/info/{packagename}", rubygems.GetPackageInfo) + r.Get("/versions", rubygems.GetAllPackagesVersions) r.Group("/api/v1/gems", func() { r.Post("/", rubygems.UploadPackageFile) r.Delete("/yank", rubygems.DeletePackage) diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index b0c4458d51..5007037bee 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -117,7 +117,7 @@ func apiErrorDefined(ctx *context.Context, err *namedError) { func apiUnauthorizedError(ctx *context.Context) { // container registry requires that the "/v2" must be in the root, so the sub-path in AppURL should be removed - realmURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), setting.AppSubURL+"/") + "/v2/token" + realmURL := httplib.GuessCurrentHostURL(ctx) + "/v2/token" ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+realmURL+`",service="container_registry",scope="*"`) apiErrorDefined(ctx, errUnauthorized) } diff --git a/routers/api/packages/rubygems/rubygems.go b/routers/api/packages/rubygems/rubygems.go index ba5f4de080..0a08378b47 100644 --- a/routers/api/packages/rubygems/rubygems.go +++ b/routers/api/packages/rubygems/rubygems.go @@ -6,6 +6,7 @@ package rubygems import ( "compress/gzip" "compress/zlib" + "crypto/md5" "errors" "fmt" "io" @@ -227,12 +228,7 @@ func UploadPackageFile(ctx *context.Context) { return } - var filename string - if rp.Metadata.Platform == "" || rp.Metadata.Platform == "ruby" { - filename = strings.ToLower(fmt.Sprintf("%s-%s.gem", rp.Name, rp.Version)) - } else { - filename = strings.ToLower(fmt.Sprintf("%s-%s-%s.gem", rp.Name, rp.Version, rp.Metadata.Platform)) - } + filename := makeGemFullFileName(rp.Name, rp.Version, rp.Metadata.Platform) _, _, err = packages_service.CreatePackageAndAddFile( ctx, @@ -300,6 +296,136 @@ func DeletePackage(ctx *context.Context) { } } +// GetPackageInfo returns a custom text based format for the single rubygem with a line for each version of the rubygem +// ref: https://guides.rubygems.org/rubygems-org-compact-index-api/ +func GetPackageInfo(ctx *context.Context) { + packageName := ctx.Params("packagename") + versions, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, packageName) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(versions) == 0 { + apiError(ctx, http.StatusNotFound, nil) + return + } + infoContent, err := makePackageInfo(ctx, versions) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + ctx.PlainText(http.StatusOK, infoContent) +} + +// GetAllPackagesVersions returns a custom text based format containing information about all versions of all rubygems. +// ref: https://guides.rubygems.org/rubygems-org-compact-index-api/ +func GetAllPackagesVersions(ctx *context.Context) { + packages, err := packages_model.GetPackagesByType(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + out := &strings.Builder{} + out.WriteString("---\n") + for _, pkg := range packages { + versions, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems, pkg.Name) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + if len(versions) == 0 { + continue + } + + info, err := makePackageInfo(ctx, versions) + if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + + // format: RUBYGEM [-]VERSION_PLATFORM[,VERSION_PLATFORM],...] MD5 + _, _ = fmt.Fprintf(out, "%s ", pkg.Name) + for i, v := range versions { + sep := util.Iif(i == len(versions)-1, "", ",") + _, _ = fmt.Fprintf(out, "%s%s", v.Version, sep) + } + _, _ = fmt.Fprintf(out, " %x\n", md5.Sum([]byte(info))) + } + + ctx.PlainText(http.StatusOK, out.String()) +} + +func writePackageVersionRequirements(prefix string, reqs []rubygems_module.VersionRequirement, out *strings.Builder) { + out.WriteString(prefix) + if len(reqs) == 0 { + reqs = []rubygems_module.VersionRequirement{{Restriction: ">=", Version: "0"}} + } + for i, req := range reqs { + sep := util.Iif(i == 0, "", "&") + _, _ = fmt.Fprintf(out, "%s%s %s", sep, req.Restriction, req.Version) + } +} + +func makePackageVersionDependency(ctx *context.Context, version *packages_model.PackageVersion) (string, error) { + // format: VERSION[-PLATFORM] [DEPENDENCY[,DEPENDENCY,...]]|REQUIREMENT[,REQUIREMENT,...] + // DEPENDENCY: GEM:CONSTRAINT[&CONSTRAINT] + // REQUIREMENT: KEY:VALUE (always contains "checksum") + pd, err := packages_model.GetPackageDescriptor(ctx, version) + if err != nil { + return "", err + } + + metadata := pd.Metadata.(*rubygems_module.Metadata) + fullFilename := makeGemFullFileName(pd.Package.Name, version.Version, metadata.Platform) + file, err := packages_model.GetFileForVersionByName(ctx, version.ID, fullFilename, "") + if err != nil { + return "", err + } + blob, err := packages_model.GetBlobByID(ctx, file.BlobID) + if err != nil { + return "", err + } + + buf := &strings.Builder{} + buf.WriteString(version.Version) + buf.WriteByte(' ') + for i, dep := range metadata.RuntimeDependencies { + sep := util.Iif(i == 0, "", ",") + writePackageVersionRequirements(fmt.Sprintf("%s%s:", sep, dep.Name), dep.Version, buf) + } + _, _ = fmt.Fprintf(buf, "|checksum:%s", blob.HashSHA256) + if len(metadata.RequiredRubyVersion) != 0 { + writePackageVersionRequirements(",ruby:", metadata.RequiredRubyVersion, buf) + } + if len(metadata.RequiredRubygemsVersion) != 0 { + writePackageVersionRequirements(",rubygems:", metadata.RequiredRubygemsVersion, buf) + } + return buf.String(), nil +} + +func makePackageInfo(ctx *context.Context, versions []*packages_model.PackageVersion) (string, error) { + ret := "---\n" + for _, v := range versions { + dep, err := makePackageVersionDependency(ctx, v) + if err != nil { + return "", err + } + ret += dep + "\n" + } + return ret, nil +} + +func makeGemFullFileName(gemName, version, platform string) string { + var basename string + if platform == "" || platform == "ruby" { + basename = fmt.Sprintf("%s-%s", gemName, version) + } else { + basename = fmt.Sprintf("%s-%s-%s", gemName, version, platform) + } + return strings.ToLower(basename) + ".gem" +} + func getVersionsByFilename(ctx *context.Context, filename string) ([]*packages_model.PackageVersion, error) { pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ OwnerID: ctx.Package.Owner.ID, diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 74062c44ac..5363489939 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1168,6 +1168,15 @@ func Routes() *web.Route { m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateTagOption{}), repo.CreateTag) m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteTag) }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) + m.Group("/tag_protections", func() { + m.Combo("").Get(repo.ListTagProtection). + Post(bind(api.CreateTagProtectionOption{}), mustNotBeArchived, repo.CreateTagProtection) + m.Group("/{id}", func() { + m.Combo("").Get(repo.GetTagProtection). + Patch(bind(api.EditTagProtectionOption{}), mustNotBeArchived, repo.EditTagProtection). + Delete(repo.DeleteTagProtection) + }) + }, reqToken(), reqAdmin()) m.Group("/actions", func() { m.Get("/tasks", repo.ListActionTasks) }, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true)) diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go index 5236fd06ae..e2ab7141b7 100644 --- a/routers/api/v1/misc/markup_test.go +++ b/routers/api/v1/misc/markup_test.go @@ -7,6 +7,7 @@ import ( go_context "context" "io" "net/http" + "path" "strings" "testing" @@ -19,36 +20,40 @@ import ( "github.com/stretchr/testify/assert" ) -const ( - AppURL = "http://localhost:3000/" - Repo = "gogits/gogs" - FullURL = AppURL + Repo + "/" -) +const AppURL = "http://localhost:3000/" -func testRenderMarkup(t *testing.T, mode, filePath, text, responseBody string, responseCode int) { +func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expectedBody string, expectedCode int) { setting.AppURL = AppURL + context := "/gogits/gogs" + if !wiki { + context += path.Join("/src/branch/main", path.Dir(filePath)) + } options := api.MarkupOption{ Mode: mode, Text: text, - Context: Repo, - Wiki: true, + Context: context, + Wiki: wiki, FilePath: filePath, } ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup") web.SetForm(ctx, &options) Markup(ctx) - assert.Equal(t, responseBody, resp.Body.String()) - assert.Equal(t, responseCode, resp.Code) + assert.Equal(t, expectedBody, resp.Body.String()) + assert.Equal(t, expectedCode, resp.Code) resp.Body.Reset() } -func testRenderMarkdown(t *testing.T, mode, text, responseBody string, responseCode int) { +func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody string, responseCode int) { setting.AppURL = AppURL + context := "/gogits/gogs" + if !wiki { + context += "/src/branch/main" + } options := api.MarkdownOption{ Mode: mode, Text: text, - Context: Repo, - Wiki: true, + Context: context, + Wiki: wiki, } ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") web.SetForm(ctx, &options) @@ -65,7 +70,7 @@ func TestAPI_RenderGFM(t *testing.T) { }, }) - testCasesCommon := []string{ + testCasesWiki := []string{ // dear imgui wiki markdown extract: special wiki syntax `Wiki! Enjoy :) - [[Links, Language bindings, Engine bindings|Links]] @@ -74,20 +79,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 ``, @@ -95,7 +100,7 @@ func TestAPI_RenderGFM(t *testing.T) { ``, } - testCasesDocument := []string{ + testCasesWikiDocument := []string{ // wine-staging wiki home extract: special wiki syntax, images `## What is Wine Staging? **Wine Staging** on website [wine-staging.com](http://wine-staging.com). @@ -111,31 +116,48 @@ 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

`, } - for i := 0; i < len(testCasesCommon); i += 2 { - text := testCasesCommon[i] - response := testCasesCommon[i+1] - testRenderMarkdown(t, "gfm", text, response, http.StatusOK) - testRenderMarkup(t, "gfm", "", text, response, http.StatusOK) - testRenderMarkdown(t, "comment", text, response, http.StatusOK) - testRenderMarkup(t, "comment", "", text, response, http.StatusOK) - testRenderMarkup(t, "file", "path/test.md", text, response, http.StatusOK) + for i := 0; i < len(testCasesWiki); i += 2 { + text := testCasesWiki[i] + response := testCasesWiki[i+1] + testRenderMarkdown(t, "gfm", true, text, response, http.StatusOK) + testRenderMarkup(t, "gfm", true, "", text, response, http.StatusOK) + testRenderMarkdown(t, "comment", true, text, response, http.StatusOK) + testRenderMarkup(t, "comment", true, "", text, response, http.StatusOK) + testRenderMarkup(t, "file", true, "path/test.md", text, response, http.StatusOK) } - for i := 0; i < len(testCasesDocument); i += 2 { - text := testCasesDocument[i] - response := testCasesDocument[i+1] - testRenderMarkdown(t, "gfm", text, response, http.StatusOK) - testRenderMarkup(t, "gfm", "", text, response, http.StatusOK) - testRenderMarkup(t, "file", "path/test.md", text, response, http.StatusOK) + for i := 0; i < len(testCasesWikiDocument); i += 2 { + text := testCasesWikiDocument[i] + response := testCasesWikiDocument[i+1] + testRenderMarkdown(t, "gfm", true, text, response, http.StatusOK) + testRenderMarkup(t, "gfm", true, "", text, response, http.StatusOK) + testRenderMarkup(t, "file", true, "path/test.md", text, response, http.StatusOK) } - testRenderMarkup(t, "file", "path/test.unknown", "## Test", "Unsupported render extension: .unknown\n", http.StatusUnprocessableEntity) - testRenderMarkup(t, "unknown", "", "## Test", "Unknown mode: unknown\n", http.StatusUnprocessableEntity) + input := "[Link](test.md)\n![Image](image.png)" + testRenderMarkdown(t, "gfm", false, input, `

Link +Image

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

Link +Image

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

Link +Image

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

Link +Image

+`, http.StatusOK) + + testRenderMarkup(t, "file", true, "path/test.unknown", "## Test", "Unsupported render extension: .unknown\n", http.StatusUnprocessableEntity) + testRenderMarkup(t, "unknown", true, "", "## Test", "Unknown mode: unknown\n", http.StatusUnprocessableEntity) } var simpleCases = []string{ @@ -160,7 +182,7 @@ func TestAPI_RenderSimple(t *testing.T) { options := api.MarkdownOption{ Mode: "markdown", Text: "", - Context: Repo, + Context: "/gogits/gogs", } ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") for i := 0; i < len(simpleCases); i += 2 { diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 9ba5887525..4f617d27af 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -184,7 +184,7 @@ func Search(ctx *context.APIContext) { if len(sortOrder) == 0 { sortOrder = "asc" } - if searchModeMap, ok := repo_model.SearchOrderByMap[sortOrder]; ok { + if searchModeMap, ok := repo_model.OrderByMap[sortOrder]; ok { if orderBy, ok := searchModeMap[sortMode]; ok { opts.OrderBy = orderBy } else { diff --git a/routers/api/v1/repo/tag.go b/routers/api/v1/repo/tag.go index 8577a0e896..f72034950f 100644 --- a/routers/api/v1/repo/tag.go +++ b/routers/api/v1/repo/tag.go @@ -7,9 +7,13 @@ import ( "errors" "fmt" "net/http" + "strings" "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" @@ -287,3 +291,349 @@ func DeleteTag(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } + +// ListTagProtection lists tag protections for a repo +func ListTagProtection(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/tag_protections repository repoListTagProtection + // --- + // summary: List tag protections for a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/TagProtectionList" + + repo := ctx.Repo.Repository + pts, err := git_model.GetProtectedTags(ctx, repo.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTags", err) + return + } + apiPts := make([]*api.TagProtection, len(pts)) + for i := range pts { + apiPts[i] = convert.ToTagProtection(ctx, pts[i], repo) + } + + ctx.JSON(http.StatusOK, apiPts) +} + +// GetTagProtection gets a tag protection +func GetTagProtection(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/tag_protections/{id} repository repoGetTagProtection + // --- + // summary: Get a specific tag protection for the repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the tag protect to get + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/TagProtection" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + id := ctx.ParamsInt64(":id") + pt, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || repo.ID != pt.RepoID { + ctx.NotFound() + return + } + + ctx.JSON(http.StatusOK, convert.ToTagProtection(ctx, pt, repo)) +} + +// CreateTagProtection creates a tag protection for a repo +func CreateTagProtection(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/tag_protections repository repoCreateTagProtection + // --- + // summary: Create a tag protections for a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateTagProtectionOption" + // responses: + // "201": + // "$ref": "#/responses/TagProtection" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateTagProtectionOption) + repo := ctx.Repo.Repository + + namePattern := strings.TrimSpace(form.NamePattern) + if namePattern == "" { + ctx.Error(http.StatusBadRequest, "name_pattern are empty", "name_pattern are empty") + return + } + + if len(form.WhitelistUsernames) == 0 && len(form.WhitelistTeams) == 0 { + ctx.Error(http.StatusBadRequest, "both whitelist_usernames and whitelist_teams are empty", "both whitelist_usernames and whitelist_teams are empty") + return + } + + pt, err := git_model.GetProtectedTagByNamePattern(ctx, repo.ID, namePattern) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectTagOfRepo", err) + return + } else if pt != nil { + ctx.Error(http.StatusForbidden, "Create tag protection", "Tag protection already exist") + return + } + + var whitelistUsers, whitelistTeams []int64 + whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.WhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + + if repo.Owner.IsOrganization() { + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.WhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } + + protectTag := &git_model.ProtectedTag{ + RepoID: repo.ID, + NamePattern: strings.TrimSpace(namePattern), + AllowlistUserIDs: whitelistUsers, + AllowlistTeamIDs: whitelistTeams, + } + if err := git_model.InsertProtectedTag(ctx, protectTag); err != nil { + ctx.Error(http.StatusInternalServerError, "InsertProtectedTag", err) + return + } + + pt, err = git_model.GetProtectedTagByID(ctx, protectTag.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.Error(http.StatusInternalServerError, "New tag protection not found", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToTagProtection(ctx, pt, repo)) +} + +// EditTagProtection edits a tag protection for a repo +func EditTagProtection(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/tag_protections/{id} repository repoEditTagProtection + // --- + // summary: Edit a tag protections for a repository. Only fields that are set will be changed + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of protected tag + // type: integer + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditTagProtectionOption" + // responses: + // "200": + // "$ref": "#/responses/TagProtection" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + repo := ctx.Repo.Repository + form := web.GetForm(ctx).(*api.EditTagProtectionOption) + + id := ctx.ParamsInt64(":id") + pt, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.NotFound() + return + } + + if form.NamePattern != nil { + pt.NamePattern = *form.NamePattern + } + + var whitelistUsers, whitelistTeams []int64 + if form.WhitelistTeams != nil { + if repo.Owner.IsOrganization() { + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.WhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } + pt.AllowlistTeamIDs = whitelistTeams + } + + if form.WhitelistUsernames != nil { + whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.WhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + pt.AllowlistUserIDs = whitelistUsers + } + + err = git_model.UpdateProtectedTag(ctx, pt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateProtectedTag", err) + return + } + + pt, err = git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.Error(http.StatusInternalServerError, "New tag protection not found", "New tag protection not found") + return + } + + ctx.JSON(http.StatusOK, convert.ToTagProtection(ctx, pt, repo)) +} + +// DeleteTagProtection +func DeleteTagProtection(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/tag_protections/{id} repository repoDeleteTagProtection + // --- + // summary: Delete a specific tag protection for the repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of protected tag + // type: integer + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + id := ctx.ParamsInt64(":id") + pt, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.NotFound() + return + } + + err = git_model.DeleteProtectedTag(ctx, pt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteProtectedTag", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index cd551cbdfa..1de58632d5 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -170,6 +170,12 @@ type swaggerParameterBodies struct { // in:body CreateTagOption api.CreateTagOption + // in:body + CreateTagProtectionOption api.CreateTagProtectionOption + + // in:body + EditTagProtectionOption api.EditTagProtectionOption + // in:body CreateAccessTokenOption api.CreateAccessTokenOption diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index fcd34a63a9..345835f9a5 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -70,6 +70,20 @@ type swaggerResponseAnnotatedTag struct { Body api.AnnotatedTag `json:"body"` } +// TagProtectionList +// swagger:response TagProtectionList +type swaggerResponseTagProtectionList struct { + // in:body + Body []api.TagProtection `json:"body"` +} + +// TagProtection +// swagger:response TagProtection +type swaggerResponseTagProtection struct { + // in:body + Body api.TagProtection `json:"body"` +} + // Reference // swagger:response Reference type swaggerResponseReference struct { diff --git a/routers/common/markup.go b/routers/common/markup.go index f7d096008a..0a00eac7d4 100644 --- a/routers/common/markup.go +++ b/routers/common/markup.go @@ -7,63 +7,67 @@ package common import ( "fmt" "net/http" + "path" "strings" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" - - "mvdan.cc/xurls/v2" ) // RenderMarkup renders markup text for the /markup and /markdown endpoints -func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPrefix, filePath string, wiki bool) { - var markupType string - relativePath := "" +func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPathContext, filePath string, wiki bool) { + // urlPathContext format is "/subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}" + // filePath is the path of the file to render if the end user is trying to preview a repo file (mode == "file") + // filePath will be used as RenderContext.RelativePath - if len(text) == 0 { - _, _ = ctx.Write([]byte("")) - return + // for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md" + // and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc" + + var markupType, relativePath string + + links := markup.Links{AbsolutePrefix: true} + if urlPathContext != "" { + links.Base = fmt.Sprintf("%s%s", httplib.GuessCurrentHostURL(ctx), urlPathContext) } switch mode { case "markdown": // Raw markdown if err := markdown.RenderRaw(&markup.RenderContext{ - Ctx: ctx, - Links: markup.Links{ - AbsolutePrefix: true, - Base: urlPrefix, - }, + Ctx: ctx, + Links: links, }, strings.NewReader(text), ctx.Resp); err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) } return case "comment": - // Comment as markdown + // Issue & comment content markupType = markdown.MarkupName case "gfm": - // Github Flavored Markdown as document + // GitHub Flavored Markdown markupType = markdown.MarkupName case "file": - // File as document based on file extension - markupType = "" + markupType = "" // render the repo file content by its extension relativePath = filePath default: ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Unknown mode: %s", mode)) return } - if !strings.HasPrefix(setting.AppSubURL+"/", urlPrefix) { - // check if urlPrefix is already set to a URL - linkRegex, _ := xurls.StrictMatchingScheme("https?://") - m := linkRegex.FindStringIndex(urlPrefix) - if m == nil { - urlPrefix = util.URLJoin(setting.AppURL, urlPrefix) - } + fields := strings.SplitN(strings.TrimPrefix(urlPathContext, setting.AppSubURL+"/"), "/", 5) + if len(fields) == 5 && fields[2] == "src" && (fields[3] == "branch" || fields[3] == "commit" || fields[3] == "tag") { + // absolute base prefix is something like "https://host/subpath/{user}/{repo}" + absoluteBasePrefix := fmt.Sprintf("%s%s/%s", httplib.GuessCurrentAppURL(ctx), fields[0], fields[1]) + + fileDir := path.Dir(filePath) // it is "doc" if filePath is "doc/CHANGE.md" + refPath := strings.Join(fields[3:], "/") // it is "branch/features/feat-12/doc" + refPath = strings.TrimSuffix(refPath, "/"+fileDir) // now we get the correct branch path: "branch/features/feat-12" + + links = markup.Links{AbsolutePrefix: true, Base: absoluteBasePrefix, BranchPath: refPath, TreePath: fileDir} } meta := map[string]string{} @@ -81,12 +85,9 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPr } if err := markup.Render(&markup.RenderContext{ - Ctx: ctx, - Repo: repoCtx, - Links: markup.Links{ - AbsolutePrefix: true, - Base: urlPrefix, - }, + Ctx: ctx, + Repo: repoCtx, + Links: links, Metas: meta, IsWiki: wiki, Type: markupType, diff --git a/routers/common/middleware.go b/routers/common/middleware.go index 8b661993bb..de49648396 100644 --- a/routers/common/middleware.go +++ b/routers/common/middleware.go @@ -25,7 +25,7 @@ import ( // ProtocolMiddlewares returns HTTP protocol related middlewares, and it provides a global panic recovery func ProtocolMiddlewares() (handlers []any) { // first, normalize the URL path - handlers = append(handlers, stripSlashesMiddleware) + handlers = append(handlers, normalizeRequestPathMiddleware) // prepare the ContextData and panic recovery handlers = append(handlers, func(next http.Handler) http.Handler { @@ -75,9 +75,9 @@ func ProtocolMiddlewares() (handlers []any) { return handlers } -func stripSlashesMiddleware(next http.Handler) http.Handler { +func normalizeRequestPathMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - // First of all escape the URL RawPath to ensure that all routing is done using a correctly escaped URL + // escape the URL RawPath to ensure that all routing is done using a correctly escaped URL req.URL.RawPath = req.URL.EscapedPath() urlPath := req.URL.RawPath @@ -86,19 +86,42 @@ func stripSlashesMiddleware(next http.Handler) http.Handler { urlPath = rctx.RoutePath } - sanitizedPath := &strings.Builder{} - prevWasSlash := false - for _, chr := range strings.TrimRight(urlPath, "/") { - if chr != '/' || !prevWasSlash { - sanitizedPath.WriteRune(chr) + normalizedPath := strings.TrimRight(urlPath, "/") + // the following code block is a slow-path for replacing all repeated slashes "//" to one single "/" + // if the path doesn't have repeated slashes, then no need to execute it + if strings.Contains(normalizedPath, "//") { + buf := &strings.Builder{} + prevWasSlash := false + for _, chr := range normalizedPath { + if chr != '/' || !prevWasSlash { + buf.WriteRune(chr) + } + prevWasSlash = chr == '/' } - prevWasSlash = chr == '/' + normalizedPath = buf.String() + } + + if setting.UseSubURLPath { + remainingPath, ok := strings.CutPrefix(normalizedPath, setting.AppSubURL+"/") + if ok { + normalizedPath = "/" + remainingPath + } else if normalizedPath == setting.AppSubURL { + normalizedPath = "/" + } else if !strings.HasPrefix(normalizedPath+"/", "/v2/") { + // do not respond to other requests, to simulate a real sub-path environment + http.Error(resp, "404 page not found, sub-path is: "+setting.AppSubURL, http.StatusNotFound) + return + } + // TODO: it's not quite clear about how req.URL and rctx.RoutePath work together. + // Fortunately, it is only used for debug purpose, we have enough time to figure it out in the future. + req.URL.RawPath = normalizedPath + req.URL.Path = normalizedPath } if rctx == nil { - req.URL.Path = sanitizedPath.String() + req.URL.Path = normalizedPath } else { - rctx.RoutePath = sanitizedPath.String() + rctx.RoutePath = normalizedPath } next.ServeHTTP(resp, req) }) diff --git a/routers/common/middleware_test.go b/routers/common/middleware_test.go index f16b9374ec..c96071c3a8 100644 --- a/routers/common/middleware_test.go +++ b/routers/common/middleware_test.go @@ -61,7 +61,7 @@ func TestStripSlashesMiddleware(t *testing.T) { }) // pass the test middleware to validate the changes - handlerToTest := stripSlashesMiddleware(testMiddleware) + handlerToTest := normalizeRequestPathMiddleware(testMiddleware) // create a mock request to use req := httptest.NewRequest("GET", tt.inputPath, nil) // call the handler using a mock response recorder diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go index 22b1fc5bf9..1d17f962f2 100644 --- a/routers/web/explore/repo.go +++ b/routers/web/explore/repo.go @@ -63,7 +63,7 @@ func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { sortOrder = setting.UI.ExploreDefaultSort } - if order, ok := repo_model.SearchOrderByFlatMap[sortOrder]; ok { + if order, ok := repo_model.OrderByFlatMap[sortOrder]; ok { orderBy = order } else { sortOrder = "recentupdate" diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 5a74971827..92f74bbf33 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -418,8 +418,9 @@ func RedirectDownload(ctx *context.Context) { tagNames := []string{vTag} curRepo := ctx.Repo.Repository releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ - RepoID: curRepo.ID, - TagNames: tagNames, + IncludeDrafts: ctx.Repo.CanWrite(unit.TypeReleases), + RepoID: curRepo.ID, + TagNames: tagNames, }) if err != nil { ctx.ServerError("RedirectDownload", err) @@ -615,7 +616,7 @@ func SearchRepo(ctx *context.Context) { if len(sortOrder) == 0 { sortOrder = "asc" } - if searchModeMap, ok := repo_model.SearchOrderByMap[sortOrder]; ok { + if searchModeMap, ok := repo_model.OrderByMap[sortOrder]; ok { if orderBy, ok := searchModeMap[sortMode]; ok { opts.OrderBy = orderBy } else { diff --git a/services/convert/convert.go b/services/convert/convert.go index c44179632e..5db33ad85d 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -408,6 +408,32 @@ func ToAnnotatedTagObject(repo *repo_model.Repository, commit *git.Commit) *api. } } +// ToTagProtection convert a git.ProtectedTag to an api.TagProtection +func ToTagProtection(ctx context.Context, pt *git_model.ProtectedTag, repo *repo_model.Repository) *api.TagProtection { + readers, err := access_model.GetRepoReaders(ctx, repo) + if err != nil { + log.Error("GetRepoReaders: %v", err) + } + + whitelistUsernames := getWhitelistEntities(readers, pt.AllowlistUserIDs) + + teamReaders, err := organization.OrgFromUser(repo.Owner).TeamsWithAccessToRepo(ctx, repo.ID, perm.AccessModeRead) + if err != nil { + log.Error("Repo.Owner.TeamsWithAccessToRepo: %v", err) + } + + whitelistTeams := getWhitelistEntities(teamReaders, pt.AllowlistTeamIDs) + + return &api.TagProtection{ + ID: pt.ID, + NamePattern: pt.NamePattern, + WhitelistUsernames: whitelistUsernames, + WhitelistTeams: whitelistTeams, + Created: pt.CreatedUnix.AsTime(), + Updated: pt.UpdatedUnix.AsTime(), + } +} + // ToTopicResponse convert from models.Topic to api.TopicResponse func ToTopicResponse(topic *repo_model.Topic) *api.TopicResponse { return &api.TopicResponse{ diff --git a/services/pull/merge.go b/services/pull/merge.go index 9ef3fb2e05..e19292c31c 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -23,6 +23,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/references" repo_module "code.gitea.io/gitea/modules/repository" @@ -56,7 +57,7 @@ func getMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issue issueReference = "!" } - reviewedOn := fmt.Sprintf("Reviewed-on: %s/%s", setting.AppURL, pr.Issue.Link()) + reviewedOn := fmt.Sprintf("Reviewed-on: %s", httplib.MakeAbsoluteURL(ctx, pr.Issue.Link())) reviewedBy := pr.GetApprovers(ctx) if mergeStyle != "" { diff --git a/services/repository/adopt_test.go b/services/repository/adopt_test.go index c1520e01c9..38949c7602 100644 --- a/services/repository/adopt_test.go +++ b/services/repository/adopt_test.go @@ -6,10 +6,13 @@ package repository import ( "os" "path" + "path/filepath" "testing" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" @@ -83,3 +86,13 @@ func TestListUnadoptedRepositories_ListOptions(t *testing.T) { assert.Equal(t, 2, count) assert.Equal(t, unadoptedList[1], repoNames[0]) } + +func TestAdoptRepository(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + assert.NoError(t, unittest.CopyDir(filepath.Join(setting.RepoRootPath, "user2", "repo1.git"), filepath.Join(setting.RepoRootPath, "user2", "test-adopt.git"))) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + _, err := AdoptRepository(db.DefaultContext, user2, user2, CreateRepoOptions{Name: "test-adopt"}) + assert.NoError(t, err) + repoTestAdopt := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "test-adopt"}) + assert.Equal(t, "sha1", repoTestAdopt.ObjectFormatName) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 09efbd4aa1..4aa64c5376 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13797,6 +13797,233 @@ } } }, + "/repos/{owner}/{repo}/tag_protections": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List tag protections for a repository", + "operationId": "repoListTagProtection", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/TagProtectionList" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create a tag protections for a repository", + "operationId": "repoCreateTagProtection", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateTagProtectionOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/TagProtection" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/tag_protections/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get a specific tag protection for the repository", + "operationId": "repoGetTagProtection", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "id of the tag protect to get", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/TagProtection" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete a specific tag protection for the repository", + "operationId": "repoDeleteTagProtection", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "id of protected tag", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Edit a tag protections for a repository. Only fields that are set will be changed", + "operationId": "repoEditTagProtection", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "id of protected tag", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditTagProtectionOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/TagProtection" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, "/repos/{owner}/{repo}/tags": { "get": { "produces": [ @@ -19954,6 +20181,31 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateTagProtectionOption": { + "description": "CreateTagProtectionOption options for creating a tag protection", + "type": "object", + "properties": { + "name_pattern": { + "type": "string", + "x-go-name": "NamePattern" + }, + "whitelist_teams": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistTeams" + }, + "whitelist_usernames": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistUsernames" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreateTeamOption": { "description": "CreateTeamOption options for creating a team", "type": "object", @@ -20870,6 +21122,31 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "EditTagProtectionOption": { + "description": "EditTagProtectionOption options for editing a tag protection", + "type": "object", + "properties": { + "name_pattern": { + "type": "string", + "x-go-name": "NamePattern" + }, + "whitelist_teams": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistTeams" + }, + "whitelist_usernames": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistUsernames" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "EditTeamOption": { "description": "EditTeamOption options for editing a team", "type": "object", @@ -22127,7 +22404,7 @@ "type": "object", "properties": { "Context": { - "description": "Context to render\n\nin: body", + "description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}\n\nin: body", "type": "string" }, "Mode": { @@ -22150,7 +22427,7 @@ "type": "object", "properties": { "Context": { - "description": "Context to render\n\nin: body", + "description": "URL path for rendering issue, media and file links\nExpected format: /subpath/{user}/{repo}/src/{branch, commit, tag}/{identifier/path}/{file/dir}\n\nin: body", "type": "string" }, "FilePath": { @@ -24024,6 +24301,46 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "TagProtection": { + "description": "TagProtection represents a tag protection", + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name_pattern": { + "type": "string", + "x-go-name": "NamePattern" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + }, + "whitelist_teams": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistTeams" + }, + "whitelist_usernames": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "WhitelistUsernames" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Team": { "description": "Team represents a team in an organization", "type": "object", @@ -25635,6 +25952,21 @@ } } }, + "TagProtection": { + "description": "TagProtection", + "schema": { + "$ref": "#/definitions/TagProtection" + } + }, + "TagProtectionList": { + "description": "TagProtectionList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/TagProtection" + } + } + }, "TasksList": { "description": "TasksList", "schema": { diff --git a/tests/integration/api_packages_rubygems_test.go b/tests/integration/api_packages_rubygems_test.go index 5670731c49..fe9283df4d 100644 --- a/tests/integration/api_packages_rubygems_test.go +++ b/tests/integration/api_packages_rubygems_test.go @@ -4,7 +4,11 @@ package integration import ( + "archive/tar" "bytes" + "compress/gzip" + "crypto/sha256" + "crypto/sha512" "encoding/base64" "fmt" "mime/multipart" @@ -21,101 +25,167 @@ import ( "github.com/stretchr/testify/assert" ) +type tarFile struct { + Name string + Data []byte +} + +func makeArchiveFileTar(files []*tarFile) []byte { + buf := new(bytes.Buffer) + tarWriter := tar.NewWriter(buf) + for _, file := range files { + _ = tarWriter.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: file.Name, + Mode: 0o644, + Size: int64(len(file.Data)), + }) + _, _ = tarWriter.Write(file.Data) + } + _ = tarWriter.Close() + return buf.Bytes() +} + +func makeArchiveFileGz(data []byte) []byte { + buf := new(bytes.Buffer) + gzWriter, _ := gzip.NewWriterLevel(buf, gzip.NoCompression) + _, _ = gzWriter.Write(data) + _ = gzWriter.Close() + return buf.Bytes() +} + +func makeRubyGem(name, version string) []byte { + metadataContent := fmt.Sprintf(`--- !ruby/object:Gem::Specification +name: %s +version: !ruby/object:Gem::Version + version: %s +platform: ruby +authors: +- Gitea +autorequire: +bindir: bin +cert_chain: [] +date: 2021-08-23 00:00:00.000000000 Z +dependencies: +- !ruby/object:Gem::Dependency + name: runtime-dep + requirement: !ruby/object:Gem::Requirement + requirements: + - - ">=" + - !ruby/object:Gem::Version + version: 1.2.0 + - - "<" + - !ruby/object:Gem::Version + version: '2.0' + type: :runtime + prerelease: false + version_requirements: !ruby/object:Gem::Requirement + requirements: + - - ">=" + - !ruby/object:Gem::Version + version: 1.2.0 + - - "<" + - !ruby/object:Gem::Version + version: '2.0' +- !ruby/object:Gem::Dependency + name: dev-dep + requirement: !ruby/object:Gem::Requirement + requirements: + - - "~>" + - !ruby/object:Gem::Version + version: '5.2' + type: :development + prerelease: false + version_requirements: !ruby/object:Gem::Requirement + requirements: + - - "~>" + - !ruby/object:Gem::Version + version: '5.2' +description: RubyGems package test +email: rubygems@gitea.io +executables: [] +extensions: [] +extra_rdoc_files: [] +files: +- lib/gitea.rb +homepage: https://gitea.io/ +licenses: +- MIT +metadata: {} +post_install_message: +rdoc_options: [] +require_paths: +- lib +required_ruby_version: !ruby/object:Gem::Requirement + requirements: + - - ">=" + - !ruby/object:Gem::Version + version: 2.3.0 +required_rubygems_version: !ruby/object:Gem::Requirement + requirements: + - - ">=" + - !ruby/object:Gem::Version + version: '1.0' +requirements: [] +rubyforge_project: +rubygems_version: 2.7.6.2 +signing_key: +specification_version: 4 +summary: Gitea package +test_files: [] +`, name, version) + + metadataGz := makeArchiveFileGz([]byte(metadataContent)) + dataTarGz := makeArchiveFileGz(makeArchiveFileTar([]*tarFile{ + { + Name: "lib/gitea.rb", + Data: []byte("class Gitea\nend"), + }, + })) + + checksumsYaml := fmt.Sprintf(`--- +SHA256: + metadata.gz: %x + data.tar.gz: %x +SHA512: + metadata.gz: %x + data.tar.gz: %x +`, sha256.Sum256(metadataGz), sha256.Sum256(dataTarGz), sha512.Sum512(metadataGz), sha512.Sum512(dataTarGz)) + + files := []*tarFile{ + { + Name: "data.tar.gz", + Data: dataTarGz, + }, + { + Name: "metadata.gz", + Data: metadataGz, + }, + { + Name: "checksums.yaml.gz", + Data: makeArchiveFileGz([]byte(checksumsYaml)), + }, + } + return makeArchiveFileTar(files) +} + func TestPackageRubyGems(t *testing.T) { defer tests.PrepareTestEnv(t)() user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - packageName := "gitea" - packageVersion := "1.0.5" - packageFilename := "gitea-1.0.5.gem" + testGemName := "gitea" + testGemVersion := "1.0.5" + testGemContent := makeRubyGem(testGemName, testGemVersion) + testGemContentChecksum := fmt.Sprintf("%x", sha256.Sum256(testGemContent)) - gemContent, _ := base64.StdEncoding.DecodeString(`bWV0YWRhdGEuZ3oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA0NDQAMDAwMDAw -MAAwMDAwMDAwADAwMDAwMDAxMDQxADE0MTEwNzcyMzY2ADAxMzQ0MQAgMAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhcgAwMHdoZWVsAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMDAwADAwMDAw -MDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf -iwgA9vQjYQID1VVNb9QwEL37V5he9pRsmlJAFlQckCoOXAriQIUix5nNmsYf2JOqKwS/nYmz2d3Q -qqCCKpFdadfjmfdm5nmcLMv4k9DXm6Wrv4BCcQ5GiPcelF5pJVE7y6w0IHirESS7hhDJJu4I+jhu -Mc53Tsd5kZ8y30lcuWAEH2KY7HHtQhQs4+cJkwwuwNdeB6JhtbaNDoLTL1MQsFJrqQnr8jNrJJJH -WZTHWfEiK094UYj0zYvp4Z9YAx5sA1ZpSCS3M30zeWwo2bG60FvUBjIKJts2GwMW76r0Yr9NzjN3 -YhwsGX2Ozl4dpcWwvK9d43PQtDIv9igvHwSyIIwFmXHjqTqxLY8MPkCADmQk80p2EfZ6VbM6/ue6 -/1D0Bq7/qeA/zh6W82leHmhFWUHn/JbsEfT6q7QbiCpoj8l0QcEUFLmX6kq2wBEiMjBSd+Pwt7T5 -Ot0kuXYMbkD1KOuOBnWYb7hBsAP4bhlkFRqnqpWefMZ/pHCn6+WIFGq2dgY8EQq+RvRRLJcTyZJ1 -WhHqGPTu7QdmACXdJFLwb9+ZdxErbSPKrqsMxJhAWCJ1qaqRdtu6yktcT/STsamG0qp7rsa5EL/K -MBua30uw4ynzExqYWRJDfx8/kQWN3PwsDh2jYLr1W+pZcAmCs9splvnz/Flesqhbq21bXcGG/OLh -+2fv/JTF3hgZyCW9OaZjxoZjdnBGfgKpxZyJ1QYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGF0 -YS50YXIuZ3oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA0NDQAMDAwMDAwMAAw -MDAwMDAwADAwMDAwMDAwMjQyADE0MTEwNzcyMzY2ADAxMzM2MQAgMAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhcgAwMHdoZWVsAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMDAwADAwMDAwMDAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfiwgA -9vQjYQID7M/NCsMgDABgz32KrA/QxersK/Q17ExXIcyhlr7+HLv1sJ02KPhBCPk5JOyn881nsl2c -xI+gRDRaC3zbZ8RBCamlxGHolTFlX11kLwDFH6wp21hO2RYi/rD3bb5/7iCubFOCMbBtABzNkIjn -bvGlAnisOUE7EnOALUR2p7b06e6aV4iqqqrquJ4AAAD//wMA+sA/NQAIAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGNoZWNr -c3Vtcy55YW1sLmd6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwNDQ0ADAwMDAwMDAAMDAw -MDAwMAAwMDAwMDAwMDQ1MAAxNDExMDc3MjM2NgAwMTQ2MTIAIDAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdXN0YXIAMDB3aGVlbAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAHdoZWVsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDAwMDAwMAAwMDAwMDAwAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4sIAPb0 -I2ECA2WQOa4UQAxE8znFXGCQ21vbPyMj5wRuL0Qk6EecnmZCyKyy9FSvXq/X4/u3ryj68Xg+f/Zn -VHzGlx+/P57qvU4XxWalBKftSXOgCjNYkdRycrC5Axem+W4HqS12PNEv7836jF9vnlHxwSyxKY+y -go0cPblyHzkrZ4HF1GSVhe7mOOoasXNk2fnbUxb+19Pp9tobD/QlJKMX7y204PREh6nQ5hG9Alw6 -x4TnmtA+aekGfm6wAseog2LSgpR4Q7cYnAH3K4qAQa6A6JCC1gpuY7P+9YxE5SZ+j0eVGbaBTwBQ -iIqRUyyzLCoFCBdYNWxniapTavD97blXTzFvgoVoAsKBAtlU48cdaOmeZDpwV01OtcGwjscfeUrY -B9QBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA -AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`) + testAnotherGemName := "gitea-another" + testAnotherGemVersion := "0.99" root := fmt.Sprintf("/api/packages/%s/rubygems", user.Name) - uploadFile := func(t *testing.T, expectedStatus int) { - req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/api/v1/gems", root), bytes.NewReader(gemContent)). + uploadFile := func(t *testing.T, content []byte, expectedStatus int) { + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/api/v1/gems", root), bytes.NewReader(content)). AddBasicAuth(user.Name) MakeRequest(t, req, expectedStatus) } @@ -123,7 +193,7 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`) t.Run("Upload", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - uploadFile(t, http.StatusCreated) + uploadFile(t, testGemContent, http.StatusCreated) pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems) assert.NoError(t, err) @@ -133,34 +203,33 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`) assert.NoError(t, err) assert.NotNil(t, pd.SemVer) assert.IsType(t, &rubygems.Metadata{}, pd.Metadata) - assert.Equal(t, packageName, pd.Package.Name) - assert.Equal(t, packageVersion, pd.Version.Version) + assert.Equal(t, testGemName, pd.Package.Name) + assert.Equal(t, testGemVersion, pd.Version.Version) pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) assert.NoError(t, err) assert.Len(t, pfs, 1) - assert.Equal(t, packageFilename, pfs[0].Name) + assert.Equal(t, fmt.Sprintf("%s-%s.gem", testGemName, testGemVersion), pfs[0].Name) assert.True(t, pfs[0].IsLead) pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) assert.NoError(t, err) - assert.Equal(t, int64(4608), pb.Size) + assert.EqualValues(t, len(testGemContent), pb.Size) }) t.Run("UploadExists", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - - uploadFile(t, http.StatusConflict) + uploadFile(t, testGemContent, http.StatusConflict) }) t.Run("Download", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "GET", fmt.Sprintf("%s/gems/%s", root, packageFilename)). + req := NewRequest(t, "GET", fmt.Sprintf("%s/gems/%s-%s.gem", root, testGemName, testGemVersion)). AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, gemContent, resp.Body.Bytes()) + assert.Equal(t, testGemContent, resp.Body.Bytes()) pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems) assert.NoError(t, err) @@ -171,7 +240,7 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`) t.Run("DownloadGemspec", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - req := NewRequest(t, "GET", fmt.Sprintf("%s/quick/Marshal.4.8/%sspec.rz", root, packageFilename)). + req := NewRequest(t, "GET", fmt.Sprintf("%s/quick/Marshal.4.8/%s-%s.gemspec.rz", root, testGemName, testGemVersion)). AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusOK) @@ -206,22 +275,63 @@ gAAAAP//MS06Gw==`) enumeratePackages(t, "prerelease_specs.4.8.gz", b) }) - t.Run("Delete", func(t *testing.T) { + t.Run("UploadAnother", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + uploadFile(t, makeRubyGem(testAnotherGemName, testAnotherGemVersion), http.StatusCreated) + }) + + t.Run("PackageInfo", func(t *testing.T) { defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, testGemName)).AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + expected := fmt.Sprintf(`--- +1.0.5 runtime-dep:>= 1.2.0&< 2.0|checksum:%s,ruby:>= 2.3.0,rubygems:>= 1.0 +`, testGemContentChecksum) + assert.Equal(t, expected, resp.Body.String()) + }) + + t.Run("Versions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", fmt.Sprintf("%s/versions", root)).AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, `--- +gitea 1.0.5 08843c2dd0ea19910e6b056b98e38f1c +gitea-another 0.99 8b639e4048d282941485368ec42609be +`, resp.Body.String()) + }) + + deleteGemPackage := func(t *testing.T, packageName, packageVersion string) { body := bytes.Buffer{} writer := multipart.NewWriter(&body) - writer.WriteField("gem_name", packageName) - writer.WriteField("version", packageVersion) - writer.Close() - + _ = writer.WriteField("gem_name", packageName) + _ = writer.WriteField("version", packageVersion) + _ = writer.Close() req := NewRequestWithBody(t, "DELETE", fmt.Sprintf("%s/api/v1/gems/yank", root), &body). SetHeader("Content-Type", writer.FormDataContentType()). AddBasicAuth(user.Name) MakeRequest(t, req, http.StatusOK) + } + t.Run("DeleteAll", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + deleteGemPackage(t, testGemName, testGemVersion) + deleteGemPackage(t, testAnotherGemName, testAnotherGemVersion) pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems) assert.NoError(t, err) assert.Empty(t, pvs) }) + + t.Run("PackageInfoAfterDelete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, testGemName)).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("VersionsAfterDelete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", fmt.Sprintf("%s/versions", root)).AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "---\n", resp.Body.String()) + }) } diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index e53d86cca0..de4f611b5d 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -272,7 +272,7 @@ export function initRepoCommentForm() { } $list.find('.selected').html(` - + ${icon} ${htmlEscape(this.textContent)}