diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 915db6cc2e..f06e9c596b 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -115,6 +115,7 @@ loading = Loading… error = Error error404 = The page you are trying to reach either <strong>does not exist</strong> or <strong>you are not authorized</strong> to view it. +error413 = You have exhausted your quota. go_back = Go Back invalid_data = Invalid data: %v @@ -2196,6 +2197,7 @@ settings.units.add_more = Add more... settings.sync_mirror = Synchronize now settings.pull_mirror_sync_in_progress = Pulling changes from the remote %s at the moment. +settings.pull_mirror_sync_quota_exceeded = Quota exceeded, not pulling changes. settings.push_mirror_sync_in_progress = Pushing changes to the remote %s at the moment. settings.site = Website settings.update_settings = Save settings @@ -2279,6 +2281,7 @@ settings.transfer_owner = New owner settings.transfer_perform = Perform transfer settings.transfer_started = This repository has been marked for transfer and awaits confirmation from "%s" settings.transfer_succeed = The repository has been transferred. +settings.transfer_quota_exceeded = The new owner (%s) is over quota. The repository has not been transferred. settings.signing_settings = Signing verification settings settings.trust_model = Signature trust model settings.trust_model.default = Default trust model diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go index 6aa0ecaaec..7b26e4703f 100644 --- a/routers/api/actions/artifacts.go +++ b/routers/api/actions/artifacts.go @@ -71,6 +71,7 @@ import ( "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -240,6 +241,18 @@ func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) { return } + // check the owner's quota + ok, err := quota_model.EvaluateForUser(ctx, ctx.ActionTask.OwnerID, quota_model.LimitSubjectSizeAssetsArtifacts) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.Error(http.StatusInternalServerError, "Error checking quota") + return + } + if !ok { + ctx.Error(http.StatusRequestEntityTooLarge, "Quota exceeded") + return + } + // get upload file size fileRealTotalSize, contentLength := getUploadFileSize(ctx) diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 57d7f9ad6f..7b2f9c4360 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -92,6 +92,7 @@ import ( "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" @@ -290,6 +291,18 @@ func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { return } + // check the owner's quota + ok, err := quota_model.EvaluateForUser(ctx, task.OwnerID, quota_model.LimitSubjectSizeAssetsArtifacts) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.Error(http.StatusInternalServerError, "Error checking quota") + return + } + if !ok { + ctx.Error(http.StatusRequestEntityTooLarge, "Quota exceeded") + return + } + comp := ctx.Req.URL.Query().Get("comp") switch comp { case "block", "appendBlock": diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index 79285783b9..f590947111 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -10,6 +10,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/perm" + quota_model "code.gitea.io/gitea/models/quota" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" @@ -74,6 +75,21 @@ func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.Context) { } } +func enforcePackagesQuota() func(ctx *context.Context) { + return func(ctx *context.Context) { + ok, err := quota_model.EvaluateForUser(ctx, ctx.Doer.ID, quota_model.LimitSubjectSizeAssetsPackagesAll) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.Error(http.StatusInternalServerError, "Error checking quota") + return + } + if !ok { + ctx.Error(http.StatusRequestEntityTooLarge, "enforcePackagesQuota", "quota exceeded") + return + } + } +} + func verifyAuth(r *web.Route, authMethods []auth.Method) { if setting.Service.EnableReverseProxyAuth { authMethods = append(authMethods, &auth.ReverseProxy{}) @@ -111,7 +127,7 @@ func CommonRoutes() *web.Route { r.Group("/alpine", func() { r.Get("/key", alpine.GetRepositoryKey) r.Group("/{branch}/{repository}", func() { - r.Put("", reqPackageAccess(perm.AccessModeWrite), alpine.UploadPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), alpine.UploadPackageFile) r.Group("/{architecture}", func() { r.Get("/APKINDEX.tar.gz", alpine.GetRepositoryFile) r.Group("/{filename}", func() { @@ -124,12 +140,12 @@ func CommonRoutes() *web.Route { r.Group("/cargo", func() { r.Group("/api/v1/crates", func() { r.Get("", cargo.SearchPackages) - r.Put("/new", reqPackageAccess(perm.AccessModeWrite), cargo.UploadPackage) + r.Put("/new", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cargo.UploadPackage) r.Group("/{package}", func() { r.Group("/{version}", func() { r.Get("/download", cargo.DownloadPackageFile) r.Delete("/yank", reqPackageAccess(perm.AccessModeWrite), cargo.YankPackage) - r.Put("/unyank", reqPackageAccess(perm.AccessModeWrite), cargo.UnyankPackage) + r.Put("/unyank", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cargo.UnyankPackage) }) r.Get("/owners", cargo.ListOwners) }) @@ -147,7 +163,7 @@ func CommonRoutes() *web.Route { r.Get("/search", chef.EnumeratePackages) r.Group("/cookbooks", func() { r.Get("", chef.EnumeratePackages) - r.Post("", reqPackageAccess(perm.AccessModeWrite), chef.UploadPackage) + r.Post("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), chef.UploadPackage) r.Group("/{name}", func() { r.Get("", chef.PackageMetadata) r.Group("/versions/{version}", func() { @@ -167,7 +183,7 @@ func CommonRoutes() *web.Route { r.Get("/p2/{vendorname}/{projectname}~dev.json", composer.PackageMetadata) r.Get("/p2/{vendorname}/{projectname}.json", composer.PackageMetadata) r.Get("/files/{package}/{version}/{filename}", composer.DownloadPackageFile) - r.Put("", reqPackageAccess(perm.AccessModeWrite), composer.UploadPackage) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), composer.UploadPackage) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/conan", func() { r.Group("/v1", func() { @@ -183,14 +199,14 @@ func CommonRoutes() *web.Route { r.Delete("", reqPackageAccess(perm.AccessModeWrite), conan.DeleteRecipeV1) r.Get("/search", conan.SearchPackagesV1) r.Get("/digest", conan.RecipeDownloadURLs) - r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), conan.RecipeUploadURLs) + r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.RecipeUploadURLs) r.Get("/download_urls", conan.RecipeDownloadURLs) r.Group("/packages", func() { r.Post("/delete", reqPackageAccess(perm.AccessModeWrite), conan.DeletePackageV1) r.Group("/{package_reference}", func() { r.Get("", conan.PackageSnapshot) r.Get("/digest", conan.PackageDownloadURLs) - r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), conan.PackageUploadURLs) + r.Post("/upload_urls", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.PackageUploadURLs) r.Get("/download_urls", conan.PackageDownloadURLs) }) }) @@ -199,11 +215,11 @@ func CommonRoutes() *web.Route { r.Group("/files/{name}/{version}/{user}/{channel}/{recipe_revision}", func() { r.Group("/recipe/{filename}", func() { r.Get("", conan.DownloadRecipeFile) - r.Put("", reqPackageAccess(perm.AccessModeWrite), conan.UploadRecipeFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadRecipeFile) }) r.Group("/package/{package_reference}/{package_revision}/{filename}", func() { r.Get("", conan.DownloadPackageFile) - r.Put("", reqPackageAccess(perm.AccessModeWrite), conan.UploadPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadPackageFile) }) }, conan.ExtractPathParameters) }) @@ -228,7 +244,7 @@ func CommonRoutes() *web.Route { r.Get("", conan.ListRecipeRevisionFiles) r.Group("/{filename}", func() { r.Get("", conan.DownloadRecipeFile) - r.Put("", reqPackageAccess(perm.AccessModeWrite), conan.UploadRecipeFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadRecipeFile) }) }) r.Group("/packages", func() { @@ -244,7 +260,7 @@ func CommonRoutes() *web.Route { r.Get("", conan.ListPackageRevisionFiles) r.Group("/{filename}", func() { r.Get("", conan.DownloadPackageFile) - r.Put("", reqPackageAccess(perm.AccessModeWrite), conan.UploadPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), conan.UploadPackageFile) }) }) }) @@ -281,7 +297,7 @@ func CommonRoutes() *web.Route { conda.DownloadPackageFile(ctx) } }) - r.Put("/*", reqPackageAccess(perm.AccessModeWrite), func(ctx *context.Context) { + r.Put("/*", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), func(ctx *context.Context) { m := uploadPattern.FindStringSubmatch(ctx.Params("*")) if len(m) == 0 { ctx.Status(http.StatusNotFound) @@ -301,7 +317,7 @@ func CommonRoutes() *web.Route { r.Get("/PACKAGES{format}", cran.EnumerateSourcePackages) r.Get("/{filename}", cran.DownloadSourcePackageFile) }) - r.Put("", reqPackageAccess(perm.AccessModeWrite), cran.UploadSourcePackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cran.UploadSourcePackageFile) }) r.Group("/bin", func() { r.Group("/{platform}/contrib/{rversion}", func() { @@ -309,7 +325,7 @@ func CommonRoutes() *web.Route { r.Get("/PACKAGES{format}", cran.EnumerateBinaryPackages) r.Get("/{filename}", cran.DownloadBinaryPackageFile) }) - r.Put("", reqPackageAccess(perm.AccessModeWrite), cran.UploadBinaryPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), cran.UploadBinaryPackageFile) }) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/debian", func() { @@ -325,13 +341,13 @@ func CommonRoutes() *web.Route { r.Group("/pool/{distribution}/{component}", func() { r.Get("/{name}_{version}_{architecture}.deb", debian.DownloadPackageFile) r.Group("", func() { - r.Put("/upload", debian.UploadPackageFile) + r.Put("/upload", enforcePackagesQuota(), debian.UploadPackageFile) r.Delete("/{name}/{version}/{architecture}", debian.DeletePackageFile) }, reqPackageAccess(perm.AccessModeWrite)) }) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/go", func() { - r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), goproxy.UploadPackage) + r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), goproxy.UploadPackage) r.Get("/sumdb/sum.golang.org/supported", func(ctx *context.Context) { ctx.Status(http.StatusNotFound) }) @@ -394,7 +410,7 @@ func CommonRoutes() *web.Route { r.Group("/{filename}", func() { r.Get("", generic.DownloadPackageFile) r.Group("", func() { - r.Put("", generic.UploadPackage) + r.Put("", enforcePackagesQuota(), generic.UploadPackage) r.Delete("", generic.DeletePackageFile) }, reqPackageAccess(perm.AccessModeWrite)) }) @@ -403,10 +419,10 @@ func CommonRoutes() *web.Route { r.Group("/helm", func() { r.Get("/index.yaml", helm.Index) r.Get("/{filename}", helm.DownloadPackageFile) - r.Post("/api/charts", reqPackageAccess(perm.AccessModeWrite), helm.UploadPackage) + r.Post("/api/charts", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), helm.UploadPackage) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/maven", func() { - r.Put("/*", reqPackageAccess(perm.AccessModeWrite), maven.UploadPackageFile) + r.Put("/*", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), maven.UploadPackageFile) r.Get("/*", maven.DownloadPackageFile) r.Head("/*", maven.ProvidePackageFileHeader) }, reqPackageAccess(perm.AccessModeRead)) @@ -427,8 +443,8 @@ func CommonRoutes() *web.Route { r.Get("/{version}/{filename}", nuget.DownloadPackageFile) }) r.Group("", func() { - r.Put("/", nuget.UploadPackage) - r.Put("/symbolpackage", nuget.UploadSymbolPackage) + r.Put("/", enforcePackagesQuota(), nuget.UploadPackage) + r.Put("/symbolpackage", enforcePackagesQuota(), nuget.UploadSymbolPackage) r.Delete("/{id}/{version}", nuget.DeletePackage) }, reqPackageAccess(perm.AccessModeWrite)) r.Get("/symbols/{filename}/{guid:[0-9a-fA-F]{32}[fF]{8}}/{filename2}", nuget.DownloadSymbolFile) @@ -450,7 +466,7 @@ func CommonRoutes() *web.Route { r.Group("/npm", func() { r.Group("/@{scope}/{id}", func() { r.Get("", npm.PackageMetadata) - r.Put("", reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), npm.UploadPackage) r.Group("/-/{version}/{filename}", func() { r.Get("", npm.DownloadPackageFile) r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) @@ -463,7 +479,7 @@ func CommonRoutes() *web.Route { }) r.Group("/{id}", func() { r.Get("", npm.PackageMetadata) - r.Put("", reqPackageAccess(perm.AccessModeWrite), npm.UploadPackage) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), npm.UploadPackage) r.Group("/-/{version}/{filename}", func() { r.Get("", npm.DownloadPackageFile) r.Delete("/-rev/{revision}", reqPackageAccess(perm.AccessModeWrite), npm.DeletePackageVersion) @@ -496,7 +512,7 @@ func CommonRoutes() *web.Route { r.Group("/api/packages", func() { r.Group("/versions/new", func() { r.Get("", pub.RequestUpload) - r.Post("/upload", pub.UploadPackageFile) + r.Post("/upload", enforcePackagesQuota(), pub.UploadPackageFile) r.Get("/finalize/{id}/{version}", pub.FinalizePackage) }, reqPackageAccess(perm.AccessModeWrite)) r.Group("/{id}", func() { @@ -507,7 +523,7 @@ func CommonRoutes() *web.Route { }) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/pypi", func() { - r.Post("/", reqPackageAccess(perm.AccessModeWrite), pypi.UploadPackageFile) + r.Post("/", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), pypi.UploadPackageFile) r.Get("/files/{id}/{version}/{filename}", pypi.DownloadPackageFile) r.Get("/simple/{id}", pypi.PackageMetadata) }, reqPackageAccess(perm.AccessModeRead)) @@ -556,6 +572,10 @@ func CommonRoutes() *web.Route { if ctx.Written() { return } + enforcePackagesQuota()(ctx) + if ctx.Written() { + return + } ctx.SetParams("group", strings.Trim(m[1], "/")) rpm.UploadPackageFile(ctx) return @@ -591,7 +611,7 @@ func CommonRoutes() *web.Route { r.Get("/quick/Marshal.4.8/{filename}", rubygems.ServePackageSpecification) r.Get("/gems/{filename}", rubygems.DownloadPackageFile) r.Group("/api/v1/gems", func() { - r.Post("/", rubygems.UploadPackageFile) + r.Post("/", enforcePackagesQuota(), rubygems.UploadPackageFile) r.Delete("/yank", rubygems.DeletePackage) }, reqPackageAccess(perm.AccessModeWrite)) }, reqPackageAccess(perm.AccessModeRead)) @@ -603,7 +623,7 @@ func CommonRoutes() *web.Route { }, swift.CheckAcceptMediaType(swift.AcceptJSON)) r.Group("/{version}", func() { r.Get("/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest) - r.Put("", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), enforcePackagesQuota(), swift.UploadPackageFile) r.Get("", func(ctx *context.Context) { // Can't use normal routes here: https://github.com/go-chi/chi/issues/781 @@ -639,7 +659,7 @@ func CommonRoutes() *web.Route { r.Get("", vagrant.EnumeratePackageVersions) r.Group("/{version}/{provider}", func() { r.Get("", vagrant.DownloadPackageFile) - r.Put("", reqPackageAccess(perm.AccessModeWrite), vagrant.UploadPackageFile) + r.Put("", reqPackageAccess(perm.AccessModeWrite), enforcePackagesQuota(), vagrant.UploadPackageFile) }) }) }, reqPackageAccess(perm.AccessModeRead)) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 263ee5fdc4..fa0cd6c753 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -77,6 +77,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -973,7 +974,7 @@ func Routes() *web.Route { // (repo scope) m.Combo("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(user.ListMyRepos). - Post(bind(api.CreateRepoOption{}), repo.Create) + Post(bind(api.CreateRepoOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetUser), repo.Create) // (repo scope) if !setting.Repository.DisableStars { @@ -1104,7 +1105,7 @@ func Routes() *web.Route { m.Get("", repo.ListBranches) m.Get("/*", repo.GetBranch) m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteBranch) - m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateBranchRepoOption{}), repo.CreateBranch) + m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateBranchRepoOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.CreateBranch) }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode)) m.Group("/branch_protections", func() { m.Get("", repo.ListBranchProtections) @@ -1118,7 +1119,7 @@ func Routes() *web.Route { m.Group("/tags", func() { m.Get("", repo.ListTags) m.Get("/*", repo.GetTag) - m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateTagOption{}), repo.CreateTag) + m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateTagOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.CreateTag) m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteTag) }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) m.Group("/tag_protections", func() { @@ -1152,10 +1153,10 @@ func Routes() *web.Route { m.Group("/wiki", func() { m.Combo("/page/{pageName}"). Get(repo.GetWikiPage). - Patch(mustNotBeArchived, reqToken(), reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.EditWikiPage). + Patch(mustNotBeArchived, reqToken(), reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.EditWikiPage). Delete(mustNotBeArchived, reqToken(), reqRepoWriter(unit.TypeWiki), repo.DeleteWikiPage) m.Get("/revisions/{pageName}", repo.ListPageRevisions) - m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), repo.NewWikiPage) + m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.NewWikiPage) m.Get("/pages", repo.ListWikiPages) }, mustEnableWiki) m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup) @@ -1172,15 +1173,15 @@ func Routes() *web.Route { }, reqToken()) m.Group("/releases", func() { m.Combo("").Get(repo.ListReleases). - Post(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.CreateReleaseOption{}), repo.CreateRelease) + Post(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.CreateReleaseOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.CreateRelease) m.Combo("/latest").Get(repo.GetLatestRelease) m.Group("/{id}", func() { m.Combo("").Get(repo.GetRelease). - Patch(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.EditReleaseOption{}), repo.EditRelease). + Patch(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.EditReleaseOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.EditRelease). Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteRelease) m.Group("/assets", func() { m.Combo("").Get(repo.ListReleaseAttachments). - Post(reqToken(), reqRepoWriter(unit.TypeReleases), repo.CreateReleaseAttachment) + Post(reqToken(), reqRepoWriter(unit.TypeReleases), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeAssetsAttachmentsReleases, context.QuotaTargetRepo), repo.CreateReleaseAttachment) m.Combo("/{attachment_id}").Get(repo.GetReleaseAttachment). Patch(reqToken(), reqRepoWriter(unit.TypeReleases), bind(api.EditAttachmentOptions{}), repo.EditReleaseAttachment). Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseAttachment) @@ -1192,7 +1193,7 @@ func Routes() *web.Route { Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag) }) }, reqRepoReader(unit.TypeReleases)) - m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync) + m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.MirrorSync) m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync) m.Group("/push_mirrors", func() { m.Combo("").Get(repo.ListPushMirrors). @@ -1211,11 +1212,11 @@ func Routes() *web.Route { m.Combo("").Get(repo.GetPullRequest). Patch(reqToken(), bind(api.EditPullRequestOption{}), repo.EditPullRequest) m.Get(".{diffType:diff|patch}", repo.DownloadPullDiffOrPatch) - m.Post("/update", reqToken(), repo.UpdatePullRequest) + m.Post("/update", reqToken(), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.UpdatePullRequest) m.Get("/commits", repo.GetPullRequestCommits) m.Get("/files", repo.GetPullRequestFiles) m.Combo("/merge").Get(repo.IsPullRequestMerged). - Post(reqToken(), mustNotBeArchived, bind(forms.MergePullRequestForm{}), repo.MergePullRequest). + Post(reqToken(), mustNotBeArchived, bind(forms.MergePullRequestForm{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.MergePullRequest). Delete(reqToken(), mustNotBeArchived, repo.CancelScheduledAutoMerge) m.Group("/reviews", func() { m.Combo(""). @@ -1270,15 +1271,15 @@ func Routes() *web.Route { m.Get("/tags/{sha}", repo.GetAnnotatedTag) m.Get("/notes/{sha}", repo.GetNote) }, context.ReferencesGitRepo(true), reqRepoReader(unit.TypeCode)) - m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), mustNotBeArchived, repo.ApplyDiffPatch) + m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.ApplyDiffPatch) m.Group("/contents", func() { m.Get("", repo.GetContentsList) - m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.ChangeFiles) + m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.ChangeFiles) m.Get("/*", repo.GetContents) m.Group("/*", func() { - m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.CreateFile) - m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.UpdateFile) - m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.DeleteFile) + m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.CreateFile) + m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.UpdateFile) + m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.DeleteFile) }, reqToken()) }, reqRepoReader(unit.TypeCode)) m.Get("/signing-key.gpg", misc.SigningKey) @@ -1335,7 +1336,7 @@ func Routes() *web.Route { m.Group("/assets", func() { m.Combo(""). Get(repo.ListIssueCommentAttachments). - Post(reqToken(), mustNotBeArchived, repo.CreateIssueCommentAttachment) + Post(reqToken(), mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeAssetsAttachmentsIssues, context.QuotaTargetRepo), repo.CreateIssueCommentAttachment) m.Combo("/{attachment_id}"). Get(repo.GetIssueCommentAttachment). Patch(reqToken(), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueCommentAttachment). @@ -1387,7 +1388,7 @@ func Routes() *web.Route { m.Group("/assets", func() { m.Combo(""). Get(repo.ListIssueAttachments). - Post(reqToken(), mustNotBeArchived, repo.CreateIssueAttachment) + Post(reqToken(), mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeAssetsAttachmentsIssues, context.QuotaTargetRepo), repo.CreateIssueAttachment) m.Combo("/{attachment_id}"). Get(repo.GetIssueAttachment). Patch(reqToken(), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueAttachment). @@ -1449,7 +1450,7 @@ func Routes() *web.Route { Patch(reqToken(), reqOrgOwnership(), bind(api.EditOrgOption{}), org.Edit). Delete(reqToken(), reqOrgOwnership(), org.Delete) m.Combo("/repos").Get(user.ListOrgRepos). - Post(reqToken(), bind(api.CreateRepoOption{}), repo.CreateOrgRepo) + Post(reqToken(), bind(api.CreateRepoOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetOrg), repo.CreateOrgRepo) m.Group("/members", func() { m.Get("", reqToken(), org.ListMembers) m.Combo("/{username}").Get(reqToken(), org.IsMember). diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index 852b7a2ee0..a468fd90d0 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -210,6 +210,8 @@ func CreateBranch(ctx *context.APIContext) { // description: The old branch does not exist. // "409": // description: The branch with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" // "423": // "$ref": "#/responses/repoArchivedError" diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index aae82894c7..1fa44d50c4 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -477,6 +477,8 @@ func ChangeFiles(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/error" // "423": @@ -579,6 +581,8 @@ func CreateFile(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/error" // "423": @@ -677,6 +681,8 @@ func UpdateFile(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/error" // "423": @@ -842,6 +848,8 @@ func DeleteFile(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" // "423": // "$ref": "#/responses/repoArchivedError" diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go index 212cc7a93b..829a977277 100644 --- a/routers/api/v1/repo/fork.go +++ b/routers/api/v1/repo/fork.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" @@ -105,6 +106,8 @@ func CreateFork(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "409": // description: The repository with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" @@ -134,6 +137,10 @@ func CreateFork(ctx *context.APIContext) { forker = org.AsUser() } + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, forker.ID, forker.Name) { + return + } + var name string if form.Name == nil { name = repo.Name diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go index 70613529c8..a972ab0374 100644 --- a/routers/api/v1/repo/issue_attachment.go +++ b/routers/api/v1/repo/issue_attachment.go @@ -160,6 +160,8 @@ func CreateIssueAttachment(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" // "423": @@ -269,6 +271,8 @@ func EditIssueAttachment(ctx *context.APIContext) { // "$ref": "#/responses/Attachment" // "404": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" // "423": // "$ref": "#/responses/repoArchivedError" diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go index 4c8516ec83..c45e2ebe89 100644 --- a/routers/api/v1/repo/issue_comment_attachment.go +++ b/routers/api/v1/repo/issue_comment_attachment.go @@ -157,6 +157,8 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" // "423": @@ -274,6 +276,8 @@ func EditIssueCommentAttachment(ctx *context.APIContext) { // "$ref": "#/responses/Attachment" // "404": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" // "423": // "$ref": "#/responses/repoArchivedError" attach := getIssueCommentAttachmentSafeWrite(ctx) diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index 14c8c01f4e..0991723d47 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/graceful" @@ -54,6 +55,8 @@ func Migrate(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "409": // description: The repository with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" @@ -85,6 +88,10 @@ func Migrate(ctx *context.APIContext) { return } + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, repoOwner.ID, repoOwner.Name) { + return + } + if !ctx.Doer.IsAdmin { if !repoOwner.IsOrganization() && ctx.Doer.ID != repoOwner.ID { ctx.Error(http.StatusForbidden, "", "Given user is not an organization.") diff --git a/routers/api/v1/repo/mirror.go b/routers/api/v1/repo/mirror.go index eddd449206..c0297d77ad 100644 --- a/routers/api/v1/repo/mirror.go +++ b/routers/api/v1/repo/mirror.go @@ -50,6 +50,8 @@ func MirrorSync(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" repo := ctx.Repo.Repository @@ -103,6 +105,8 @@ func PushMirrorSync(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" if !setting.Mirror.Enabled { ctx.Error(http.StatusBadRequest, "PushMirrorSync", "Mirror feature is disabled") @@ -279,6 +283,8 @@ func AddPushMirror(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" if !setting.Mirror.Enabled { ctx.Error(http.StatusBadRequest, "AddPushMirror", "Mirror feature is disabled") diff --git a/routers/api/v1/repo/patch.go b/routers/api/v1/repo/patch.go index 0e0601b7d9..27c5c17dce 100644 --- a/routers/api/v1/repo/patch.go +++ b/routers/api/v1/repo/patch.go @@ -47,6 +47,8 @@ func ApplyDiffPatch(ctx *context.APIContext) { // "$ref": "#/responses/FileResponse" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" // "423": // "$ref": "#/responses/repoArchivedError" apiOpts := web.GetForm(ctx).(*api.ApplyDiffPatchFileOptions) diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index d5bed1f640..4ae4d08814 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -387,6 +387,8 @@ func CreatePullRequest(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "409": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" // "423": @@ -857,6 +859,8 @@ func MergePullRequest(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "409": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" // "423": // "$ref": "#/responses/repoArchivedError" @@ -1218,6 +1222,8 @@ func UpdatePullRequest(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "409": // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go index 5e43f2987a..d569f6e928 100644 --- a/routers/api/v1/repo/release_attachment.go +++ b/routers/api/v1/repo/release_attachment.go @@ -201,6 +201,8 @@ func CreateReleaseAttachment(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" // Check if attachments are enabled if !setting.Attachment.Enabled { @@ -348,6 +350,8 @@ func EditReleaseAttachment(ctx *context.APIContext) { // "$ref": "#/responses/Attachment" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" form := web.GetForm(ctx).(*api.EditAttachmentOptions) diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 05a63bc62b..9f6536b2c5 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -302,6 +303,8 @@ func Create(ctx *context.APIContext) { // "$ref": "#/responses/error" // "409": // description: The repository with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" opt := web.GetForm(ctx).(*api.CreateRepoOption) @@ -346,6 +349,8 @@ func Generate(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "409": // description: The repository with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" form := web.GetForm(ctx).(*api.GenerateRepoOption) @@ -412,6 +417,10 @@ func Generate(ctx *context.APIContext) { } } + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + repo, err := repo_service.GenerateRepository(ctx, ctx.Doer, ctxUser, ctx.Repo.Repository, opts) if err != nil { if repo_model.IsErrRepoAlreadyExist(err) { diff --git a/routers/api/v1/repo/tag.go b/routers/api/v1/repo/tag.go index c050883768..7dbdd1fcbd 100644 --- a/routers/api/v1/repo/tag.go +++ b/routers/api/v1/repo/tag.go @@ -208,6 +208,8 @@ func CreateTag(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "409": // "$ref": "#/responses/conflict" + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" // "423": diff --git a/routers/api/v1/repo/transfer.go b/routers/api/v1/repo/transfer.go index 94c6bc6ded..0715aed064 100644 --- a/routers/api/v1/repo/transfer.go +++ b/routers/api/v1/repo/transfer.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" @@ -53,6 +54,8 @@ func Transfer(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" // "422": // "$ref": "#/responses/validationError" @@ -76,6 +79,10 @@ func Transfer(ctx *context.APIContext) { } } + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, newOwner.ID, newOwner.Name) { + return + } + var teams []*organization.Team if opts.TeamIDs != nil { if !newOwner.IsOrganization() { @@ -162,6 +169,8 @@ func AcceptTransfer(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" err := acceptOrRejectRepoTransfer(ctx, true) if ctx.Written() { @@ -233,6 +242,11 @@ func acceptOrRejectRepoTransfer(ctx *context.APIContext, accept bool) error { } if accept { + recipient := repoTransfer.Recipient + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, recipient.ID, recipient.Name) { + return nil + } + return repo_service.TransferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams) } diff --git a/routers/api/v1/repo/wiki.go b/routers/api/v1/repo/wiki.go index 1b92c7bceb..12aaa8edf8 100644 --- a/routers/api/v1/repo/wiki.go +++ b/routers/api/v1/repo/wiki.go @@ -53,6 +53,8 @@ func NewWikiPage(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" // "423": // "$ref": "#/responses/repoArchivedError" @@ -131,6 +133,8 @@ func EditWikiPage(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" // "423": // "$ref": "#/responses/repoArchivedError" diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 8f7575c1db..4b8439d2da 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -15,11 +15,13 @@ import ( issues_model "code.gitea.io/gitea/models/issues" perm_model "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/private" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" gitea_context "code.gitea.io/gitea/services/context" pull_service "code.gitea.io/gitea/services/pull" @@ -47,6 +49,8 @@ type preReceiveContext struct { opts *private.HookOptions + isOverQuota bool + branchName string } @@ -140,6 +144,36 @@ func (ctx *preReceiveContext) assertPushOptions() bool { return true } +func (ctx *preReceiveContext) checkQuota() error { + if !setting.Quota.Enabled { + ctx.isOverQuota = false + return nil + } + + if !ctx.loadPusherAndPermission() { + ctx.isOverQuota = true + return nil + } + + ok, err := quota_model.EvaluateForUser(ctx, ctx.PrivateContext.Repo.Repository.OwnerID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + UserMsg: "Error checking user quota", + }) + return err + } + + ctx.isOverQuota = !ok + return nil +} + +func (ctx *preReceiveContext) quotaExceeded() { + ctx.JSON(http.StatusRequestEntityTooLarge, private.Response{ + UserMsg: "Quota exceeded", + }) +} + // HookPreReceive checks whether a individual commit is acceptable func HookPreReceive(ctx *gitea_context.PrivateContext) { opts := web.GetForm(ctx).(*private.HookOptions) @@ -156,6 +190,10 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) { } log.Trace("Git push options validation succeeded") + if err := ourCtx.checkQuota(); err != nil { + return + } + // Iterate across the provided old commit IDs for i := range opts.OldCommitIDs { oldCommitID := opts.OldCommitIDs[i] @@ -170,6 +208,10 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) { case git.SupportProcReceive && refFullName.IsFor(): preReceiveFor(ourCtx, oldCommitID, newCommitID, refFullName) default: + if ourCtx.isOverQuota { + ourCtx.quotaExceeded() + return + } ourCtx.AssertCanWriteCode() } if ctx.Written() { @@ -211,6 +253,11 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r // Allow pushes to non-protected branches if protectBranch == nil { + // ...unless the user is over quota, and the operation is not a delete + if newCommitID != objectFormat.EmptyObjectID().String() && ctx.isOverQuota { + ctx.quotaExceeded() + } + return } protectBranch.Repo = repo @@ -452,6 +499,15 @@ func preReceiveTag(ctx *preReceiveContext, oldCommitID, newCommitID string, refF }) return } + + // If the user is over quota, and the push isn't a tag deletion, deny it + if ctx.isOverQuota { + objectFormat := ctx.Repo.GetObjectFormat() + if newCommitID != objectFormat.EmptyObjectID().String() { + ctx.quotaExceeded() + return + } + } } func preReceiveFor(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) { //nolint:unparam diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index 97b0c425ea..70113e50ec 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models" admin_model "code.gitea.io/gitea/models/admin" "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" @@ -170,6 +171,10 @@ func MigratePost(ctx *context.Context) { tpl := base.TplName("repo/migrate/" + form.Service.Name()) + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + if ctx.HasError() { ctx.HTML(http.StatusOK, tpl) return @@ -260,6 +265,25 @@ func setMigrationContextData(ctx *context.Context, serviceType structs.GitServic } func MigrateRetryPost(ctx *context.Context) { + ok, err := quota_model.EvaluateForUser(ctx, ctx.Repo.Repository.OwnerID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + ctx.ServerError("quota_model.EvaluateForUser", err) + return + } + if !ok { + if err := task.SetMigrateTaskMessage(ctx, ctx.Repo.Repository.ID, ctx.Locale.TrString("repo.settings.pull_mirror_sync_quota_exceeded")); err != nil { + log.Error("SetMigrateTaskMessage failed: %v", err) + ctx.ServerError("task.SetMigrateTaskMessage", err) + return + } + ctx.JSON(http.StatusRequestEntityTooLarge, map[string]any{ + "ok": false, + "error": ctx.Tr("repo.settings.pull_mirror_sync_quota_exceeded"), + }) + return + } + if err := task.RetryMigrateTask(ctx, ctx.Repo.Repository.ID); err != nil { log.Error("Retry task failed: %v", err) ctx.ServerError("task.RetryMigrateTask", err) diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index be6511afaa..aa1f506483 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" pull_model "code.gitea.io/gitea/models/pull" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -250,6 +251,10 @@ func ForkPost(ctx *context.Context) { ctx.Data["ContextUser"] = ctxUser + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + if ctx.HasError() { ctx.HTML(http.StatusOK, tplFork) return diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 711a1e1e12..7e20d3afaa 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -17,6 +17,7 @@ import ( git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -240,6 +241,10 @@ func CreatePost(ctx *context.Context) { } ctx.Data["ContextUser"] = ctxUser + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + if ctx.HasError() { ctx.HTML(http.StatusOK, tplCreate) return @@ -363,49 +368,56 @@ func ActionTransfer(accept bool) func(ctx *context.Context) { action = "reject_transfer" } - err := acceptOrRejectRepoTransfer(ctx, accept) + ok, err := acceptOrRejectRepoTransfer(ctx, accept) if err != nil { ctx.ServerError(fmt.Sprintf("Action (%s)", action), err) return } + if !ok { + return + } ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.Repo.RepoLink) } } -func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error { +func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) (bool, error) { repoTransfer, err := models.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) if err != nil { - return err + return false, err } if err := repoTransfer.LoadAttributes(ctx); err != nil { - return err + return false, err } if !repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer) { - return errors.New("user does not have enough permissions") + return false, errors.New("user does not have enough permissions") } if accept { + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctx.Doer.ID, ctx.Doer.Name) { + return false, nil + } + if ctx.Repo.GitRepo != nil { ctx.Repo.GitRepo.Close() ctx.Repo.GitRepo = nil } if err := repo_service.TransferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams); err != nil { - return err + return false, err } ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) } else { if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { - return err + return false, err } ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) } ctx.Redirect(ctx.Repo.Repository.Link()) - return nil + return true, nil } // RedirectDownload return a file based on the following infos: diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 66e96b9961..7da622101f 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -17,6 +17,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -518,6 +519,20 @@ func SettingsPost(ctx *context.Context) { return } + ok, err := quota_model.EvaluateForUser(ctx, repo.OwnerID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + ctx.ServerError("quota_model.EvaluateForUser", err) + return + } + if !ok { + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + ctx.RenderWithErr(ctx.Tr("repo.settings.pull_mirror_sync_quota_exceeded"), tplSettingsOptions, &form) + return + } + mirror_service.AddPullMirrorToQueue(repo.ID) ctx.Flash.Info(ctx.Tr("repo.settings.pull_mirror_sync_in_progress", repo.OriginalURL)) @@ -828,6 +843,17 @@ func SettingsPost(ctx *context.Context) { } } + // Check the quota of the new owner + ok, err := quota_model.EvaluateForUser(ctx, newOwner.ID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + ctx.ServerError("quota_model.EvaluateForUser", err) + return + } + if !ok { + ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_quota_exceeded", newOwner.Name), tplSettingsOptions, &form) + return + } + // Close the GitRepo if open if ctx.Repo.GitRepo != nil { ctx.Repo.GitRepo.Close() diff --git a/routers/web/web.go b/routers/web/web.go index edf0769a4b..dccb391270 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -11,6 +11,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" + quota_model "code.gitea.io/gitea/models/quota" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/metrics" @@ -1196,7 +1197,7 @@ func registerRoutes(m *web.Route) { m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.SetShowOutdatedComments, repo.UpdateResolveConversation) - m.Post("/attachments", repo.UploadIssueAttachment) + m.Post("/attachments", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeAssetsAttachmentsIssues, context.QuotaTargetRepo), repo.UploadIssueAttachment) m.Post("/attachments/remove", repo.DeleteAttachment) m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin) m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove) @@ -1244,9 +1245,9 @@ func registerRoutes(m *web.Route) { Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) m.Combo("/_cherrypick/{sha:([a-f0-9]{4,64})}/*").Get(repo.CherryPick). Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost) - }, repo.MustBeEditable, repo.CommonEditorData) + }, repo.MustBeEditable, repo.CommonEditorData, context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)) m.Group("", func() { - m.Post("/upload-file", repo.UploadFileToServer) + m.Post("/upload-file", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.UploadFileToServer) m.Post("/upload-remove", web.Bind(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer) }, repo.MustBeEditable, repo.MustBeAbleToUpload) }, context.RepoRef(), canEnableEditor, context.RepoMustNotBeArchived()) @@ -1256,7 +1257,7 @@ func registerRoutes(m *web.Route) { m.Post("/branch/*", context.RepoRefByType(context.RepoRefBranch), repo.CreateBranch) m.Post("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.CreateBranch) m.Post("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.CreateBranch) - }, web.Bind(forms.NewBranchForm{})) + }, web.Bind(forms.NewBranchForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)) m.Post("/delete", repo.DeleteBranchPost) m.Post("/restore", repo.RestoreBranchPost) }, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty) @@ -1288,16 +1289,17 @@ func registerRoutes(m *web.Route) { m.Get("/releases/attachments/{uuid}", repo.MustBeNotEmpty, repo.GetAttachment) m.Get("/releases/download/{vTag}/{fileName}", repo.MustBeNotEmpty, repo.RedirectDownload) m.Group("/releases", func() { - m.Get("/new", repo.NewRelease) - m.Post("/new", web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost) + m.Combo("/new", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)). + Get(repo.NewRelease). + Post(web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost) m.Post("/delete", repo.DeleteRelease) - m.Post("/attachments", repo.UploadReleaseAttachment) + m.Post("/attachments", context.EnforceQuotaWeb(quota_model.LimitSubjectSizeAssetsAttachmentsReleases, context.QuotaTargetRepo), repo.UploadReleaseAttachment) m.Post("/attachments/remove", repo.DeleteAttachment) }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, context.RepoRef()) m.Group("/releases", func() { m.Get("/edit/*", repo.EditRelease) m.Post("/edit/*", web.Bind(forms.EditReleaseForm{}), repo.EditReleasePost) - }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, repo.CommitInfoCache) + }, reqSignIn, repo.MustBeNotEmpty, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, repo.CommitInfoCache, context.EnforceQuotaWeb(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo)) }, ignSignIn, context.RepoAssignment, context.UnitTypes(), reqRepoReleaseReader) // to maintain compatibility with old attachments @@ -1410,10 +1412,10 @@ func registerRoutes(m *web.Route) { m.Group("/wiki", func() { m.Combo("/"). Get(repo.Wiki). - Post(context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter, web.Bind(forms.NewWikiForm{}), repo.WikiPost) + Post(context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter, web.Bind(forms.NewWikiForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.WikiPost) m.Combo("/*"). Get(repo.Wiki). - Post(context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter, web.Bind(forms.NewWikiForm{}), repo.WikiPost) + Post(context.RepoMustNotBeArchived(), reqSignIn, reqRepoWikiWriter, web.Bind(forms.NewWikiForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.WikiPost) m.Get("/commit/{sha:[a-f0-9]{4,64}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.Diff) m.Get("/commit/{sha:[a-f0-9]{4,64}}.{ext:patch|diff}", repo.RawDiff) }, repo.MustEnableWiki, func(ctx *context.Context) { @@ -1490,7 +1492,7 @@ func registerRoutes(m *web.Route) { m.Get("/list", context.RepoRef(), repo.GetPullCommits) m.Get("/{sha:[a-f0-9]{4,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForSingleCommit) }) - m.Post("/merge", context.RepoMustNotBeArchived(), web.Bind(forms.MergePullRequestForm{}), repo.MergePullRequest) + m.Post("/merge", context.RepoMustNotBeArchived(), web.Bind(forms.MergePullRequestForm{}), context.EnforceQuotaWeb(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.MergePullRequest) m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest) m.Post("/update", repo.UpdatePullRequest) m.Post("/set_allow_maintainer_edit", web.Bind(forms.UpdateAllowEditsForm{}), repo.SetAllowEdits) diff --git a/services/context/quota.go b/services/context/quota.go index 1022e7453a..94e8847696 100644 --- a/services/context/quota.go +++ b/services/context/quota.go @@ -4,11 +4,30 @@ package context import ( + "context" "net/http" + "strings" quota_model "code.gitea.io/gitea/models/quota" + "code.gitea.io/gitea/modules/base" ) +type QuotaTargetType int + +const ( + QuotaTargetUser QuotaTargetType = iota + QuotaTargetRepo + QuotaTargetOrg +) + +// QuotaExceeded +// swagger:response quotaExceeded +type APIQuotaExceeded struct { + Message string `json:"message"` + UserID int64 `json:"user_id"` + UserName string `json:"username,omitempty"` +} + // QuotaGroupAssignmentAPI returns a middleware to handle context-quota-group assignment for api routes func QuotaGroupAssignmentAPI() func(ctx *APIContext) { return func(ctx *APIContext) { @@ -42,3 +61,140 @@ func QuotaRuleAssignmentAPI() func(ctx *APIContext) { ctx.QuotaRule = rule } } + +// ctx.CheckQuota checks whether the user in question is within quota limits (web context) +func (ctx *Context) CheckQuota(subject quota_model.LimitSubject, userID int64, username string) bool { + ok, err := checkQuota(ctx.Base.originCtx, subject, userID, username, func(userID int64, username string) { + showHTML := false + for _, part := range ctx.Req.Header["Accept"] { + if strings.Contains(part, "text/html") { + showHTML = true + break + } + } + if !showHTML { + ctx.plainTextInternal(3, http.StatusRequestEntityTooLarge, []byte("Quota exceeded.\n")) + return + } + + ctx.Data["IsRepo"] = ctx.Repo.Repository != nil + ctx.Data["Title"] = "Quota Exceeded" + ctx.HTML(http.StatusRequestEntityTooLarge, base.TplName("status/413")) + }, func(err error) { + ctx.Error(http.StatusInternalServerError, "quota_model.EvaluateForUser") + }) + if err != nil { + return false + } + return ok +} + +// ctx.CheckQuota checks whether the user in question is within quota limits (API context) +func (ctx *APIContext) CheckQuota(subject quota_model.LimitSubject, userID int64, username string) bool { + ok, err := checkQuota(ctx.Base.originCtx, subject, userID, username, func(userID int64, username string) { + ctx.JSON(http.StatusRequestEntityTooLarge, APIQuotaExceeded{ + Message: "quota exceeded", + UserID: userID, + UserName: username, + }) + }, func(err error) { + ctx.InternalServerError(err) + }) + if err != nil { + return false + } + return ok +} + +// EnforceQuotaWeb returns a middleware that enforces quota limits on the given web route. +func EnforceQuotaWeb(subject quota_model.LimitSubject, target QuotaTargetType) func(ctx *Context) { + return func(ctx *Context) { + ctx.CheckQuota(subject, target.UserID(ctx), target.UserName(ctx)) + } +} + +// EnforceQuotaWeb returns a middleware that enforces quota limits on the given API route. +func EnforceQuotaAPI(subject quota_model.LimitSubject, target QuotaTargetType) func(ctx *APIContext) { + return func(ctx *APIContext) { + ctx.CheckQuota(subject, target.UserID(ctx), target.UserName(ctx)) + } +} + +// checkQuota wraps quota checking into a single function +func checkQuota(ctx context.Context, subject quota_model.LimitSubject, userID int64, username string, quotaExceededHandler func(userID int64, username string), errorHandler func(err error)) (bool, error) { + ok, err := quota_model.EvaluateForUser(ctx, userID, subject) + if err != nil { + errorHandler(err) + return false, err + } + if !ok { + quotaExceededHandler(userID, username) + return false, nil + } + return true, nil +} + +type QuotaContext interface { + GetQuotaTargetUserID(target QuotaTargetType) int64 + GetQuotaTargetUserName(target QuotaTargetType) string +} + +func (ctx *Context) GetQuotaTargetUserID(target QuotaTargetType) int64 { + switch target { + case QuotaTargetUser: + return ctx.Doer.ID + case QuotaTargetRepo: + return ctx.Repo.Repository.OwnerID + case QuotaTargetOrg: + return ctx.Org.Organization.ID + default: + return 0 + } +} + +func (ctx *Context) GetQuotaTargetUserName(target QuotaTargetType) string { + switch target { + case QuotaTargetUser: + return ctx.Doer.Name + case QuotaTargetRepo: + return ctx.Repo.Repository.Owner.Name + case QuotaTargetOrg: + return ctx.Org.Organization.Name + default: + return "" + } +} + +func (ctx *APIContext) GetQuotaTargetUserID(target QuotaTargetType) int64 { + switch target { + case QuotaTargetUser: + return ctx.Doer.ID + case QuotaTargetRepo: + return ctx.Repo.Repository.OwnerID + case QuotaTargetOrg: + return ctx.Org.Organization.ID + default: + return 0 + } +} + +func (ctx *APIContext) GetQuotaTargetUserName(target QuotaTargetType) string { + switch target { + case QuotaTargetUser: + return ctx.Doer.Name + case QuotaTargetRepo: + return ctx.Repo.Repository.Owner.Name + case QuotaTargetOrg: + return ctx.Org.Organization.Name + default: + return "" + } +} + +func (target QuotaTargetType) UserID(ctx QuotaContext) int64 { + return ctx.GetQuotaTargetUserID(target) +} + +func (target QuotaTargetType) UserName(ctx QuotaContext) string { + return ctx.GetQuotaTargetUserName(target) +} diff --git a/services/lfs/server.go b/services/lfs/server.go index ace501e15f..a300de19c4 100644 --- a/services/lfs/server.go +++ b/services/lfs/server.go @@ -23,6 +23,7 @@ import ( git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -179,6 +180,18 @@ func BatchHandler(ctx *context.Context) { return } + if isUpload { + ok, err := quota_model.EvaluateForUser(ctx, ctx.Doer.ID, quota_model.LimitSubjectSizeGitLFS) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + writeStatus(ctx, http.StatusInternalServerError) + return + } + if !ok { + writeStatusMessage(ctx, http.StatusRequestEntityTooLarge, "quota exceeded") + } + } + contentStore := lfs_module.NewContentStore() var responseObjects []*lfs_module.ObjectResponse @@ -297,6 +310,18 @@ func UploadHandler(ctx *context.Context) { return } + if exists { + ok, err := quota_model.EvaluateForUser(ctx, ctx.Doer.ID, quota_model.LimitSubjectSizeGitLFS) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + writeStatus(ctx, http.StatusInternalServerError) + return + } + if !ok { + writeStatusMessage(ctx, http.StatusRequestEntityTooLarge, "quota exceeded") + } + } + uploadOrVerify := func() error { if exists { accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid) diff --git a/services/mirror/mirror.go b/services/mirror/mirror.go index 44218d6fb3..bc2d6711cf 100644 --- a/services/mirror/mirror.go +++ b/services/mirror/mirror.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + quota_model "code.gitea.io/gitea/models/quota" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" @@ -73,6 +74,19 @@ func Update(ctx context.Context, pullLimit, pushLimit int) error { default: } + // Check if the repo's owner is over quota, for pull mirrors + if mirrorType == PullMirrorType { + ok, err := quota_model.EvaluateForUser(ctx, repo.OwnerID, quota_model.LimitSubjectSizeReposAll) + if err != nil { + log.Error("quota_model.EvaluateForUser: %v", err) + return err + } + if !ok { + log.Trace("Owner quota exceeded for %-v, not syncing", repo) + return nil + } + } + // Push to the Queue if err := PushToQueue(mirrorType, referenceID); err != nil { if err == queue.ErrAlreadyInQueue { diff --git a/services/task/task.go b/services/task/task.go index c90ee91270..ac659ac3e5 100644 --- a/services/task/task.go +++ b/services/task/task.go @@ -152,3 +152,18 @@ func RetryMigrateTask(ctx context.Context, repoID int64) error { return taskQueue.Push(migratingTask) } + +func SetMigrateTaskMessage(ctx context.Context, repoID int64, message string) error { + migratingTask, err := admin_model.GetMigratingTask(ctx, repoID) + if err != nil { + log.Error("GetMigratingTask: %v", err) + return err + } + + migratingTask.Message = message + if err = migratingTask.UpdateCols(ctx, "message"); err != nil { + log.Error("task.UpdateCols failed: %v", err) + return err + } + return nil +} diff --git a/templates/status/413.tmpl b/templates/status/413.tmpl new file mode 100644 index 0000000000..8248f601b2 --- /dev/null +++ b/templates/status/413.tmpl @@ -0,0 +1,11 @@ +{{template "base/head" .}} +<div role="main" aria-label="{{.Title}}" class="page-content ui center tw-w-screen {{if .IsRepo}}repository{{end}}"> + {{if .IsRepo}}{{template "repo/header" .}}{{end}} + <div class="ui container center"> + <h1 style="margin-top: 100px" class="error-code">413</h1> + <p>{{ctx.Locale.Tr "error413"}}</p> + <div class="divider"></div> + <br> + </div> +</div> +{{template "base/footer" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index ffb4f34bb0..39b92f4e79 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -4306,6 +4306,9 @@ "409": { "description": "The repository with the same name already exists." }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" } @@ -5612,6 +5615,9 @@ "409": { "description": "The branch with the same name already exists." }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "423": { "$ref": "#/responses/repoArchivedError" } @@ -6348,6 +6354,9 @@ "404": { "$ref": "#/responses/notFound" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/error" }, @@ -6458,6 +6467,9 @@ "404": { "$ref": "#/responses/notFound" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/error" }, @@ -6519,6 +6531,9 @@ "404": { "$ref": "#/responses/notFound" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/error" }, @@ -6583,6 +6598,9 @@ "404": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "423": { "$ref": "#/responses/repoArchivedError" } @@ -6633,6 +6651,9 @@ "404": { "$ref": "#/responses/notFound" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "423": { "$ref": "#/responses/repoArchivedError" } @@ -7034,6 +7055,9 @@ "409": { "description": "The repository with the same name already exists." }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" } @@ -8506,6 +8530,9 @@ "404": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" }, @@ -8677,6 +8704,9 @@ "404": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "423": { "$ref": "#/responses/repoArchivedError" } @@ -9135,6 +9165,9 @@ "404": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" }, @@ -9306,6 +9339,9 @@ "404": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "423": { "$ref": "#/responses/repoArchivedError" } @@ -11979,6 +12015,9 @@ }, "404": { "$ref": "#/responses/notFound" + }, + "413": { + "$ref": "#/responses/quotaExceeded" } } } @@ -12311,6 +12350,9 @@ "409": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" }, @@ -12813,6 +12855,9 @@ "409": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "423": { "$ref": "#/responses/repoArchivedError" } @@ -13671,6 +13716,9 @@ "409": { "$ref": "#/responses/error" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" } @@ -13777,6 +13825,9 @@ }, "404": { "$ref": "#/responses/notFound" + }, + "413": { + "$ref": "#/responses/quotaExceeded" } } } @@ -13819,6 +13870,9 @@ }, "404": { "$ref": "#/responses/notFound" + }, + "413": { + "$ref": "#/responses/quotaExceeded" } } } @@ -14443,6 +14497,9 @@ }, "404": { "$ref": "#/responses/notFound" + }, + "413": { + "$ref": "#/responses/quotaExceeded" } } } @@ -14605,6 +14662,9 @@ }, "404": { "$ref": "#/responses/notFound" + }, + "413": { + "$ref": "#/responses/quotaExceeded" } } } @@ -15359,6 +15419,9 @@ "409": { "$ref": "#/responses/conflict" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" }, @@ -15991,6 +16054,9 @@ "404": { "$ref": "#/responses/notFound" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" } @@ -16032,6 +16098,9 @@ }, "404": { "$ref": "#/responses/notFound" + }, + "413": { + "$ref": "#/responses/quotaExceeded" } } } @@ -16121,6 +16190,9 @@ "404": { "$ref": "#/responses/notFound" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "423": { "$ref": "#/responses/repoArchivedError" } @@ -16265,6 +16337,9 @@ "404": { "$ref": "#/responses/notFound" }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "423": { "$ref": "#/responses/repoArchivedError" } @@ -16417,6 +16492,9 @@ "409": { "description": "The repository with the same name already exists." }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" } @@ -18514,6 +18592,9 @@ "409": { "description": "The repository with the same name already exists." }, + "413": { + "$ref": "#/responses/quotaExceeded" + }, "422": { "$ref": "#/responses/validationError" } @@ -28110,6 +28191,21 @@ "$ref": "#/definitions/SetUserQuotaGroupsOptions" } }, + "quotaExceeded": { + "description": "QuotaExceeded", + "headers": { + "message": { + "type": "string" + }, + "user_id": { + "type": "integer", + "format": "int64" + }, + "username": { + "type": "string" + } + } + }, "redirect": { "description": "APIRedirect is a redirect response" }, diff --git a/tests/integration/api_quota_use_test.go b/tests/integration/api_quota_use_test.go new file mode 100644 index 0000000000..846ced34b1 --- /dev/null +++ b/tests/integration/api_quota_use_test.go @@ -0,0 +1,1436 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" + 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/migration" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type quotaEnvUser struct { + User *user_model.User + Session *TestSession + Token string +} + +type quotaEnvOrgs struct { + Unlimited api.Organization + Limited api.Organization +} + +type quotaEnv struct { + Admin quotaEnvUser + User quotaEnvUser + Dummy quotaEnvUser + + Repo *repo_model.Repository + Orgs quotaEnvOrgs + + cleanups []func() +} + +func (e *quotaEnv) APIPathForRepo(uriFormat string, a ...any) string { + path := fmt.Sprintf(uriFormat, a...) + return fmt.Sprintf("/api/v1/repos/%s/%s%s", e.User.User.Name, e.Repo.Name, path) +} + +func (e *quotaEnv) Cleanup() { + for i := len(e.cleanups) - 1; i >= 0; i-- { + e.cleanups[i]() + } +} + +func (e *quotaEnv) WithoutQuota(t *testing.T, task func(), rules ...string) { + rule := "all" + if rules != nil { + rule = rules[0] + } + defer e.SetRuleLimit(t, rule, -1)() + task() +} + +func (e *quotaEnv) SetupWithSingleQuotaRule(t *testing.T) { + t.Helper() + + cleaner := test.MockVariableValue(&setting.Quota.Enabled, true) + e.cleanups = append(e.cleanups, cleaner) + cleaner = test.MockVariableValue(&testWebRoutes, routers.NormalRoutes()) + e.cleanups = append(e.cleanups, cleaner) + + // Create a default group + cleaner = createQuotaGroup(t, "default") + e.cleanups = append(e.cleanups, cleaner) + + // Create a single all-encompassing rule + unlimited := int64(-1) + ruleAll := api.CreateQuotaRuleOptions{ + Name: "all", + Limit: &unlimited, + Subjects: []string{"size:all"}, + } + cleaner = createQuotaRule(t, ruleAll) + e.cleanups = append(e.cleanups, cleaner) + + // Add these rules to the group + cleaner = e.AddRuleToGroup(t, "default", "all") + e.cleanups = append(e.cleanups, cleaner) + + // Add the user to the quota group + cleaner = e.AddUserToGroup(t, "default", e.User.User.Name) + e.cleanups = append(e.cleanups, cleaner) +} + +func (e *quotaEnv) AddDummyUser(t *testing.T, username string) { + t.Helper() + + userCleanup := apiCreateUser(t, username) + e.Dummy.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) + e.Dummy.Session = loginUser(t, e.Dummy.User.Name) + e.Dummy.Token = getTokenForLoggedInUser(t, e.Dummy.Session, auth_model.AccessTokenScopeAll) + e.cleanups = append(e.cleanups, userCleanup) + + // Add the user to the "limited" group. See AddLimitedOrg + cleaner := e.AddUserToGroup(t, "limited", username) + e.cleanups = append(e.cleanups, cleaner) +} + +func (e *quotaEnv) AddLimitedOrg(t *testing.T) { + t.Helper() + + // Create the limited org + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", api.CreateOrgOption{ + UserName: "limited-org", + }).AddTokenAuth(e.User.Token) + resp := e.User.Session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &e.Orgs.Limited) + e.cleanups = append(e.cleanups, func() { + req := NewRequest(t, "DELETE", "/api/v1/orgs/limited-org"). + AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + }) + + // Create a group for the org + cleaner := createQuotaGroup(t, "limited") + e.cleanups = append(e.cleanups, cleaner) + + // Create a single all-encompassing rule + zero := int64(0) + ruleDenyAll := api.CreateQuotaRuleOptions{ + Name: "deny-all", + Limit: &zero, + Subjects: []string{"size:all"}, + } + cleaner = createQuotaRule(t, ruleDenyAll) + e.cleanups = append(e.cleanups, cleaner) + + // Add these rules to the group + cleaner = e.AddRuleToGroup(t, "limited", "deny-all") + e.cleanups = append(e.cleanups, cleaner) + + // Add the user to the quota group + cleaner = e.AddUserToGroup(t, "limited", e.Orgs.Limited.UserName) + e.cleanups = append(e.cleanups, cleaner) +} + +func (e *quotaEnv) AddUnlimitedOrg(t *testing.T) { + t.Helper() + + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", api.CreateOrgOption{ + UserName: "unlimited-org", + }).AddTokenAuth(e.User.Token) + resp := e.User.Session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &e.Orgs.Unlimited) + e.cleanups = append(e.cleanups, func() { + req := NewRequest(t, "DELETE", "/api/v1/orgs/unlimited-org"). + AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + }) +} + +func (e *quotaEnv) SetupWithMultipleQuotaRules(t *testing.T) { + t.Helper() + + cleaner := test.MockVariableValue(&setting.Quota.Enabled, true) + e.cleanups = append(e.cleanups, cleaner) + cleaner = test.MockVariableValue(&testWebRoutes, routers.NormalRoutes()) + e.cleanups = append(e.cleanups, cleaner) + + // Create a default group + cleaner = createQuotaGroup(t, "default") + e.cleanups = append(e.cleanups, cleaner) + + // Create three rules: all, repo-size, and asset-size + zero := int64(0) + ruleAll := api.CreateQuotaRuleOptions{ + Name: "all", + Limit: &zero, + Subjects: []string{"size:all"}, + } + cleaner = createQuotaRule(t, ruleAll) + e.cleanups = append(e.cleanups, cleaner) + + fifteenMb := int64(1024 * 1024 * 15) + ruleRepoSize := api.CreateQuotaRuleOptions{ + Name: "repo-size", + Limit: &fifteenMb, + Subjects: []string{"size:repos:all"}, + } + cleaner = createQuotaRule(t, ruleRepoSize) + e.cleanups = append(e.cleanups, cleaner) + + ruleAssetSize := api.CreateQuotaRuleOptions{ + Name: "asset-size", + Limit: &fifteenMb, + Subjects: []string{"size:assets:all"}, + } + cleaner = createQuotaRule(t, ruleAssetSize) + e.cleanups = append(e.cleanups, cleaner) + + // Add these rules to the group + cleaner = e.AddRuleToGroup(t, "default", "all") + e.cleanups = append(e.cleanups, cleaner) + cleaner = e.AddRuleToGroup(t, "default", "repo-size") + e.cleanups = append(e.cleanups, cleaner) + cleaner = e.AddRuleToGroup(t, "default", "asset-size") + e.cleanups = append(e.cleanups, cleaner) + + // Add the user to the quota group + cleaner = e.AddUserToGroup(t, "default", e.User.User.Name) + e.cleanups = append(e.cleanups, cleaner) +} + +func (e *quotaEnv) AddUserToGroup(t *testing.T, group, user string) func() { + t.Helper() + + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/%s/users/%s", group, user).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + + return func() { + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s/users/%s", group, user).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + } +} + +func (e *quotaEnv) SetRuleLimit(t *testing.T, rule string, limit int64) func() { + t.Helper() + + originalRule, err := quota_model.GetRuleByName(db.DefaultContext, rule) + require.NoError(t, err) + assert.NotNil(t, originalRule) + + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/admin/quota/rules/%s", rule), api.EditQuotaRuleOptions{ + Limit: &limit, + }).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusOK) + + return func() { + e.SetRuleLimit(t, rule, originalRule.Limit) + } +} + +func (e *quotaEnv) RemoveRuleFromGroup(t *testing.T, group, rule string) { + t.Helper() + + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s/rules/%s", group, rule).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) +} + +func (e *quotaEnv) AddRuleToGroup(t *testing.T, group, rule string) func() { + t.Helper() + + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/%s/rules/%s", group, rule).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + + return func() { + e.RemoveRuleFromGroup(t, group, rule) + } +} + +func prepareQuotaEnv(t *testing.T, username string) *quotaEnv { + t.Helper() + + env := quotaEnv{} + + // Set up the admin user + env.Admin.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + env.Admin.Session = loginUser(t, env.Admin.User.Name) + env.Admin.Token = getTokenForLoggedInUser(t, env.Admin.Session, auth_model.AccessTokenScopeAll) + + // Create a test user + userCleanup := apiCreateUser(t, username) + env.User.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) + env.User.Session = loginUser(t, env.User.User.Name) + env.User.Token = getTokenForLoggedInUser(t, env.User.Session, auth_model.AccessTokenScopeAll) + env.cleanups = append(env.cleanups, userCleanup) + + // Create a repository + repo, _, repoCleanup := CreateDeclarativeRepoWithOptions(t, env.User.User, DeclarativeRepoOptions{}) + env.Repo = repo + env.cleanups = append(env.cleanups, repoCleanup) + + return &env +} + +func TestAPIQuotaUserCleanSlate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + defer test.MockVariableValue(&setting.Quota.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + env := prepareQuotaEnv(t, "qt-clean-slate") + defer env.Cleanup() + + t.Run("branch creation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a branch + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "branch-to-delete", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + }) +} + +func TestAPIQuotaEnforcement(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + testAPIQuotaEnforcement(t) + }) +} + +func TestAPIQuotaCountsTowardsCorrectUser(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := prepareQuotaEnv(t, "quota-correct-user-test") + defer env.Cleanup() + env.SetupWithSingleQuotaRule(t) + + // Create a new group, with size:all set to 0 + defer createQuotaGroup(t, "limited")() + zero := int64(0) + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ + Name: "limited", + Limit: &zero, + Subjects: []string{"size:all"}, + })() + defer env.AddRuleToGroup(t, "limited", "limited")() + + // Add the admin user to it + defer env.AddUserToGroup(t, "limited", env.Admin.User.Name)() + + // Add the admin user as collaborator to our repo + perm := "admin" + req := NewRequestWithJSON(t, "PUT", + env.APIPathForRepo("/collaborators/%s", env.Admin.User.Name), + api.AddCollaboratorOption{ + Permission: &perm, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + + // Now, try to push something as admin! + req = NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "admin-branch", + }).AddTokenAuth(env.Admin.Token) + env.Admin.Session.MakeRequest(t, req, http.StatusCreated) + }) +} + +func TestAPIQuotaError(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := prepareQuotaEnv(t, "quota-enforcement") + defer env.Cleanup() + env.SetupWithSingleQuotaRule(t) + env.AddUnlimitedOrg(t) + env.AddLimitedOrg(t) + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Limited.UserName, + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + + var msg context.APIQuotaExceeded + DecodeJSON(t, resp, &msg) + + assert.EqualValues(t, env.Orgs.Limited.ID, msg.UserID) + assert.Equal(t, env.Orgs.Limited.UserName, msg.UserName) + }) +} + +func testAPIQuotaEnforcement(t *testing.T) { + env := prepareQuotaEnv(t, "quota-enforcement") + defer env.Cleanup() + env.SetupWithSingleQuotaRule(t) + env.AddUnlimitedOrg(t) + env.AddLimitedOrg(t) + env.AddDummyUser(t, "qe-dummy") + + t.Run("#/user/repos", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0)() + + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", api.CreateRepoOption{ + Name: "quota-exceeded", + AutoInit: true, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/repos").AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + }) + + t.Run("#/orgs/{org}/repos", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0) + + assertCreateRepo := func(t *testing.T, orgName, repoName string, expectedStatus int) func() { + t.Helper() + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), api.CreateRepoOption{ + Name: repoName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, expectedStatus) + + return func() { + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s", orgName, repoName). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + } + } + + t.Run("limited", func(t *testing.T) { + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", env.Orgs.Unlimited.UserName). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertCreateRepo(t, env.Orgs.Limited.UserName, "test-repo", http.StatusRequestEntityTooLarge) + }) + }) + + t.Run("unlimited", func(t *testing.T) { + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + defer assertCreateRepo(t, env.Orgs.Unlimited.UserName, "test-repo", http.StatusCreated)() + }) + }) + }) + + t.Run("#/repos/migrate", func(t *testing.T) { + t.Run("to:limited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", api.MigrateRepoOptions{ + CloneAddr: env.Repo.HTMLURL() + ".git", + RepoName: "quota-migrate", + Service: "forgejo", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("to:unlimited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", api.MigrateRepoOptions{ + CloneAddr: "an-invalid-address", + RepoName: "quota-migrate", + RepoOwner: env.Orgs.Unlimited.UserName, + Service: "forgejo", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + }) + + t.Run("#/repos/{template_owner}/{template_repo}/generate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a template repository + template, _, cleanup := CreateDeclarativeRepoWithOptions(t, env.User.User, DeclarativeRepoOptions{ + IsTemplate: optional.Some(true), + }) + defer cleanup() + + // Drop the quota to 0 + defer env.SetRuleLimit(t, "all", 0)() + + t.Run("to: limited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", template.APIURL()+"/generate", api.GenerateRepoOption{ + Owner: env.User.User.Name, + Name: "generated-repo", + GitContent: true, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("to: unlimited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", template.APIURL()+"/generate", api.GenerateRepoOption{ + Owner: env.Orgs.Unlimited.UserName, + Name: "generated-repo", + GitContent: true, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/generated-repo", env.Orgs.Unlimited.UserName). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + + t.Run("#/repos/{username}/{reponame}", func(t *testing.T) { + // Lets create a new repo to play with. + repo, _, repoCleanup := CreateDeclarativeRepoWithOptions(t, env.User.User, DeclarativeRepoOptions{}) + defer repoCleanup() + + // Drop the quota to 0 + defer env.SetRuleLimit(t, "all", 0)() + + deleteRepo := func(t *testing.T, path string) { + t.Helper() + + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s", path). + AddTokenAuth(env.Admin.Token) + env.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + } + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s", env.User.User.Name, repo.Name). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("PATCH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + desc := "Some description" + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", env.User.User.Name, repo.Name), api.EditRepoOption{ + Description: &desc, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s", env.User.User.Name, repo.Name). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + + t.Run("branches", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a branch we can delete later + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "to-delete", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/branches")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "quota-exceeded", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{branch}", func(t *testing.T) { + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/branches/to-delete")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/branches/to-delete")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + + t.Run("contents", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var fileSha string + + // Create a file to play with + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents/plaything.txt"), api.CreateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var r api.FileResponse + DecodeJSON(t, resp, &r) + + fileSha = r.Content.SHA + }) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/contents")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents"), api.ChangeFilesOptions{ + Files: []*api.ChangeFileOperation{ + { + Operation: "create", + Path: "quota-exceeded.txt", + }, + }, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{filepath}", func(t *testing.T) { + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/contents/plaything.txt")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents/plaything.txt"), api.CreateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PUT", env.APIPathForRepo("/contents/plaything.txt"), api.UpdateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + DeleteFileOptions: api.DeleteFileOptions{ + SHA: fileSha, + }, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Deleting a file fails, because it creates a new commit, + // which would increase the quota use. + req := NewRequestWithJSON(t, "DELETE", env.APIPathForRepo("/contents/plaything.txt"), api.DeleteFileOptions{ + SHA: fileSha, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + }) + }) + + t.Run("diffpatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PUT", env.APIPathForRepo("/contents/README.md"), api.UpdateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + DeleteFileOptions: api.DeleteFileOptions{ + SHA: "c0ffeebabe", + }, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("forks", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("as: limited user", func(t *testing.T) { + // Our current user (env.User) is already limited here. + + t.Run("into: limited org", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Limited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("into: unlimited org", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Unlimited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusAccepted) + + deleteRepo(t, env.Orgs.Unlimited.UserName+"/"+env.Repo.Name) + }) + }) + t.Run("as: unlimited user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Lift the quota limits on our current user temporarily + defer env.SetRuleLimit(t, "all", -1)() + + t.Run("into: limited org", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Limited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("into: unlimited org", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Unlimited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusAccepted) + + deleteRepo(t, env.Orgs.Unlimited.UserName+"/"+env.Repo.Name) + }) + }) + }) + + t.Run("mirror-sync", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var mirrorRepo *repo_model.Repository + env.WithoutQuota(t, func() { + // Create a mirror repo + opts := migration.MigrateOptions{ + RepoName: "test_mirror", + Description: "Test mirror", + Private: false, + Mirror: true, + CloneAddr: repo_model.RepoPath(env.User.User.Name, env.Repo.Name), + Wiki: true, + Releases: false, + } + + repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, env.User.User, env.User.User, repo_service.CreateRepoOptions{ + Name: opts.RepoName, + Description: opts.Description, + IsPrivate: opts.Private, + IsMirror: opts.Mirror, + Status: repo_model.RepositoryBeingMigrated, + }) + require.NoError(t, err) + + mirrorRepo = repo + }) + + req := NewRequestf(t, "POST", "/api/v1/repos/%s/mirror-sync", mirrorRepo.FullName()). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("issues", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create an issue play with + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/issues"), api.CreateIssueOption{ + Title: "quota test issue", + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var issue api.Issue + DecodeJSON(t, resp, &issue) + + createAsset := func(filename string) (*bytes.Buffer, string) { + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("attachment", filename) + io.Copy(part, &buff) + writer.Close() + + return body, writer.FormDataContentType() + } + + t.Run("{index}/assets", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/issues/%d/assets", issue.Index)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body, contentType := createAsset("overquota.png") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/%d/assets", issue.Index), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", contentType) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{attachment_id}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var issueAsset api.Attachment + env.WithoutQuota(t, func() { + body, contentType := createAsset("test.png") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/%d/assets", issue.Index), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", contentType) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + DecodeJSON(t, resp, &issueAsset) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/issues/%d/assets/%d", issue.Index, issueAsset.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/issues/%d/assets/%d", issue.Index, issueAsset.ID), api.EditAttachmentOptions{ + Name: "new-name.png", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/issues/%d/assets/%d", issue.Index, issueAsset.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + + t.Run("comments/{id}/assets", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a new comment! + var comment api.Comment + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/issues/%d/comments", issue.Index), api.CreateIssueCommentOption{ + Body: "This is a comment", + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &comment) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/issues/comments/%d/assets", comment.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body, contentType := createAsset("overquota.png") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/comments/%d/assets", comment.ID), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", contentType) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{attachment_id}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var attachment api.Attachment + env.WithoutQuota(t, func() { + body, contentType := createAsset("test.png") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/comments/%d/assets", comment.ID), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", contentType) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &attachment) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/issues/comments/%d/assets/%d", comment.ID, attachment.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/issues/comments/%d/assets/%d", comment.ID, attachment.ID), api.EditAttachmentOptions{ + Name: "new-name.png", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/issues/comments/%d/assets/%d", comment.ID, attachment.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + }) + + t.Run("pulls", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Fork the repository into the unlimited org first + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Unlimited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusAccepted) + + defer deleteRepo(t, env.Orgs.Unlimited.UserName+"/"+env.Repo.Name) + + // Create a pull request! + // + // Creating a pull request this way does not increase the space of + // the base repo, so is not subject to quota enforcement. + + req = NewRequestWithJSON(t, "POST", env.APIPathForRepo("/pulls"), api.CreatePullRequestOption{ + Base: "main", + Title: "test-pr", + Head: fmt.Sprintf("%s:main", env.Orgs.Unlimited.UserName), + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var pr api.PullRequest + DecodeJSON(t, resp, &pr) + + t.Run("{index}", func(t *testing.T) { + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/pulls/%d", pr.Index)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/pulls/%d", pr.Index), api.EditPullRequestOption{ + Title: "Updated title", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("merge", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/pulls/%d/merge", pr.Index), forms.MergePullRequestForm{ + Do: "merge", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + }) + }) + + t.Run("releases", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var releaseID int64 + + // Create a release so that there's something to play with. + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{ + TagName: "play-release-tag", + Title: "play-release", + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var q api.Release + DecodeJSON(t, resp, &q) + + releaseID = q.ID + }) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{ + TagName: "play-release-tag-two", + Title: "play-release-two", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("tags/{tag}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a release for our subtests + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{ + TagName: "play-release-tag-subtest", + Title: "play-release-subtest", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases/tags/play-release-tag-subtest")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/releases/tags/play-release-tag-subtest")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + + t.Run("{id}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var tmpReleaseID int64 + + // Create a release so that there's something to play with. + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{ + TagName: "tmp-tag", + Title: "tmp-release", + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var q api.Release + DecodeJSON(t, resp, &q) + + tmpReleaseID = q.ID + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases/%d", tmpReleaseID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/releases/%d", tmpReleaseID), api.EditReleaseOption{ + TagName: "tmp-tag-two", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/releases/%d", tmpReleaseID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + + t.Run("assets", func(t *testing.T) { + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases/%d/assets", releaseID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body := strings.NewReader("hello world") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/releases/%d/assets?name=bar.txt", releaseID), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", "text/plain") + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{attachment_id}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var attachmentID int64 + + // Create an attachment to play with + env.WithoutQuota(t, func() { + body := strings.NewReader("hello world") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/releases/%d/assets?name=foo.txt", releaseID), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", "text/plain") + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var q api.Attachment + DecodeJSON(t, resp, &q) + + attachmentID = q.ID + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases/%d/assets/%d", releaseID, attachmentID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/releases/%d/assets/%d", releaseID, attachmentID), api.EditAttachmentOptions{ + Name: "new-name.txt", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/releases/%d/assets/%d", releaseID, attachmentID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + }) + }) + + t.Run("tags", func(t *testing.T) { + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/tags")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/tags"), api.CreateTagOption{ + TagName: "tag-quota-test", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{tag}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/tags"), api.CreateTagOption{ + TagName: "tag-quota-test-2", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/tags/tag-quota-test-2")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/tags/tag-quota-test-2")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + + t.Run("transfer", func(t *testing.T) { + t.Run("to: limited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a repository to transfer + repo, _, cleanup := CreateDeclarativeRepoWithOptions(t, env.User.User, DeclarativeRepoOptions{}) + defer cleanup() + + // Initiate repo transfer + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", env.User.User.Name, repo.Name), api.TransferRepoOption{ + NewOwner: env.Dummy.User.Name, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + + // Initiate it outside of quotas, so we can test accept/reject. + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", env.User.User.Name, repo.Name), api.TransferRepoOption{ + NewOwner: env.Dummy.User.Name, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }, "deny-all") // a bit of a hack, sorry! + + // Try to accept the repo transfer + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", env.User.User.Name, repo.Name)). + AddTokenAuth(env.Dummy.Token) + env.Dummy.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + + // Then reject it. + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", env.User.User.Name, repo.Name)). + AddTokenAuth(env.Dummy.Token) + env.Dummy.Session.MakeRequest(t, req, http.StatusOK) + }) + + t.Run("to: unlimited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Disable the quota for the dummy user + defer env.SetRuleLimit(t, "deny-all", -1)() + + // Create a repository to transfer + repo, _, cleanup := CreateDeclarativeRepoWithOptions(t, env.User.User, DeclarativeRepoOptions{}) + defer cleanup() + + // Initiate repo transfer + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", env.User.User.Name, repo.Name), api.TransferRepoOption{ + NewOwner: env.Dummy.User.Name, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + + // Accept the repo transfer + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", env.User.User.Name, repo.Name)). + AddTokenAuth(env.Dummy.Token) + env.Dummy.Session.MakeRequest(t, req, http.StatusAccepted) + }) + }) + }) + + t.Run("#/packages/{owner}/{type}/{name}/{version}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0)() + + // Create a generic package to play with + env.WithoutQuota(t, func() { + body := strings.NewReader("forgejo is awesome") + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/quota-test/1.0.0/test.txt", env.User.User.Name), body). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body := strings.NewReader("forgejo is awesome") + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/quota-test/1.0.0/overquota.txt", env.User.User.Name), body). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "/api/v1/packages/%s/generic/quota-test/1.0.0", env.User.User.Name). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "DELETE", "/api/v1/packages/%s/generic/quota-test/1.0.0", env.User.User.Name). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) +} + +func TestAPIQuotaOrgQuotaQuery(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := prepareQuotaEnv(t, "quota-enforcement") + defer env.Cleanup() + + env.SetupWithSingleQuotaRule(t) + env.AddUnlimitedOrg(t) + env.AddLimitedOrg(t) + + // Look at the quota use of our user, and the unlimited org, for later + // comparison. + var userInfo api.QuotaInfo + req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &userInfo) + + var orgInfo api.QuotaInfo + req = NewRequestf(t, "GET", "/api/v1/orgs/%s/quota", env.Orgs.Unlimited.Name). + AddTokenAuth(env.User.Token) + resp = env.User.Session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &orgInfo) + + assert.Positive(t, userInfo.Used.Size.Repos.Public) + assert.EqualValues(t, 0, orgInfo.Used.Size.Repos.Public) + }) +} + +func TestAPIQuotaUserBasics(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := prepareQuotaEnv(t, "quota-enforcement") + defer env.Cleanup() + + env.SetupWithMultipleQuotaRules(t) + + t.Run("quota usage change", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaInfo + DecodeJSON(t, resp, &q) + + assert.Positive(t, q.Used.Size.Repos.Public) + assert.Empty(t, q.Groups[0].Name) + assert.Empty(t, q.Groups[0].Rules[0].Name) + + t.Run("admin view", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "/api/v1/admin/users/%s/quota", env.User.User.Name).AddTokenAuth(env.Admin.Token) + resp := env.Admin.Session.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaInfo + DecodeJSON(t, resp, &q) + + assert.Positive(t, q.Used.Size.Repos.Public) + + assert.NotEmpty(t, q.Groups[0].Name) + assert.NotEmpty(t, q.Groups[0].Rules[0].Name) + }) + }) + + t.Run("quota check passing", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/quota/check?subject=size:repos:all").AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusOK) + + var q bool + DecodeJSON(t, resp, &q) + + assert.True(t, q) + }) + + t.Run("quota check failing after limit change", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "repo-size", 0)() + + req := NewRequest(t, "GET", "/api/v1/user/quota/check?subject=size:repos:all").AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusOK) + + var q bool + DecodeJSON(t, resp, &q) + + assert.False(t, q) + }) + + t.Run("quota enforcement", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "repo-size", 0)() + + t.Run("repoCreateFile", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents/new-file.txt"), api.CreateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("repoCreateBranch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "new-branch", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("repoDeleteBranch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Temporarily disable quota checking + defer env.SetRuleLimit(t, "repo-size", -1)() + defer env.SetRuleLimit(t, "all", -1)() + + // Create a branch + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "branch-to-delete", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + + // Set the limit back. No need to defer, the first one will set it + // back to the correct value. + env.SetRuleLimit(t, "all", 0) + env.SetRuleLimit(t, "repo-size", 0) + + // Deleting a branch does not incur quota enforcement + req = NewRequest(t, "DELETE", env.APIPathForRepo("/branches/branch-to-delete")).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) +} diff --git a/tests/integration/quota_use_test.go b/tests/integration/quota_use_test.go new file mode 100644 index 0000000000..b1fa15045a --- /dev/null +++ b/tests/integration/quota_use_test.go @@ -0,0 +1,1099 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + quota_model "code.gitea.io/gitea/models/quota" + 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/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + forgejo_context "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + gouuid "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWebQuotaEnforcementRepoMigrate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + env.RunVisitAndPostToPageTests(t, "/repo/migrate", &Payload{ + "repo_name": "migration-test", + "clone_addr": env.Users.Limited.Repo.Link() + ".git", + "service": fmt.Sprintf("%d", api.ForgejoService), + }, http.StatusOK) + }) +} + +func TestWebQuotaEnforcementRepoCreate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + env.RunVisitAndPostToPageTests(t, "/repo/create", nil, http.StatusOK) + }) +} + +func TestWebQuotaEnforcementRepoFork(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + page := fmt.Sprintf("%s/fork", env.Users.Limited.Repo.Link()) + env.RunVisitAndPostToPageTests(t, page, &Payload{ + "repo_name": "fork-test", + }, http.StatusSeeOther) + }) +} + +func TestWebQuotaEnforcementIssueAttachment(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + // Uploading to our repo => 413 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + CreateIssueAttachment("test.txt"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Uploading to the limited org repo => 413 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + CreateIssueAttachment("test.txt"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Uploading to the unlimited org repo => 200 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Unlimited.Repo}). + CreateIssueAttachment("test.txt"). + ExpectStatus(http.StatusOK) + }) +} + +func TestWebQuotaEnforcementMirrorSync(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + var mirrorRepo *repo_model.Repository + + env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + mirrorRepo = ctx.CreateMirror() + }). + With(Context{ + Repo: mirrorRepo, + Payload: &Payload{"action": "mirror-sync"}, + }). + PostToPage(mirrorRepo.Link() + "/settings"). + ExpectStatus(http.StatusOK). + ExpectFlashMessage("Quota exceeded, not pulling changes.") + }) +} + +func TestWebQuotaEnforcementRepoContentEditing(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + // We're only going to test the GET requests here, because the entire combo + // is covered by a route check. + + // Lets create a helper! + runCheck := func(t *testing.T, path string, successStatus int) { + t.Run("#"+path, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Uploading to a limited user's repo => 413 + env.As(t, env.Users.Limited). + VisitPage(env.Users.Limited.Repo.Link() + path). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Limited org => 413 + env.As(t, env.Users.Limited). + VisitPage(env.Orgs.Limited.Repo.Link() + path). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Unlimited org => 200 + env.As(t, env.Users.Limited). + VisitPage(env.Orgs.Unlimited.Repo.Link() + path). + ExpectStatus(successStatus) + }) + } + + paths := []string{ + "/_new/main", + "/_edit/main/README.md", + "/_delete/main", + "/_upload/main", + "/_diffpatch/main", + } + + for _, path := range paths { + runCheck(t, path, http.StatusOK) + } + + // Run another check for `_cherrypick`. It's cumbersome to dig out a valid + // commit id, so we'll use a fake, and treat 404 as a success: it's not 413, + // and that's all we care about for this test. + runCheck(t, "/_cherrypick/92cfceb39d57d914ed8b14d0e37643de0797ae56/main", http.StatusNotFound) + }) +} + +func TestWebQuotaEnforcementRepoBranches(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + t.Run("create", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + runTest := func(t *testing.T, path string) { + t.Run("#"+path, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + env.As(t, env.Users.Limited). + With(Context{Payload: &Payload{"new_branch_name": "quota"}}). + PostToRepoPage("/branches/_new" + path). + ExpectStatus(http.StatusRequestEntityTooLarge) + + env.As(t, env.Users.Limited). + With(Context{ + Payload: &Payload{"new_branch_name": "quota"}, + Repo: env.Orgs.Limited.Repo, + }). + PostToRepoPage("/branches/_new" + path). + ExpectStatus(http.StatusRequestEntityTooLarge) + + env.As(t, env.Users.Limited). + With(Context{ + Payload: &Payload{"new_branch_name": "quota"}, + Repo: env.Orgs.Unlimited.Repo, + }). + PostToRepoPage("/branches/_new" + path). + ExpectStatus(http.StatusNotFound) + }) + } + + // We're testing the first two against things that don't exist, so that + // all three consistently return 404 if no quota enforcement happens. + runTest(t, "/branch/no-such-branch") + runTest(t, "/tag/no-such-tag") + runTest(t, "/commit/92cfceb39d57d914ed8b14d0e37643de0797ae56") + }) + + t.Run("delete & restore", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + ctx.With(Context{Payload: &Payload{"new_branch_name": "to-delete"}}). + PostToRepoPage("/branches/_new/branch/main"). + ExpectStatus(http.StatusSeeOther) + }) + + env.As(t, env.Users.Limited). + PostToRepoPage("/branches/delete?name=to-delete"). + ExpectStatus(http.StatusOK) + + env.As(t, env.Users.Limited). + PostToRepoPage("/branches/restore?name=to-delete"). + ExpectStatus(http.StatusOK) + }) + }) +} + +func TestWebQuotaEnforcementRepoReleases(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + env.RunVisitAndPostToRepoPageTests(t, "/releases/new", &Payload{ + "tag_name": "quota", + "tag_target": "main", + "title": "test release", + }, http.StatusSeeOther) + + t.Run("attachments", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Uploading to our repo => 413 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + CreateReleaseAttachment("test.txt"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Uploading to the limited org repo => 413 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + CreateReleaseAttachment("test.txt"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Uploading to the unlimited org repo => 200 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Unlimited.Repo}). + CreateReleaseAttachment("test.txt"). + ExpectStatus(http.StatusOK) + }) + }) +} + +func TestWebQuotaEnforcementRepoPulls(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + // To create a pull request, we first fork the two limited repos into the + // unlimited org. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + ForkRepoInto(env.Orgs.Unlimited) + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + ForkRepoInto(env.Orgs.Unlimited) + + // Then, create pull requests from the forks, back to the main repos + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + CreatePullFrom(env.Orgs.Unlimited) + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + CreatePullFrom(env.Orgs.Unlimited) + + // Trying to merge the pull request will fail for both, though, due to being + // over quota. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + With(Context{Payload: &Payload{"do": "merge"}}). + PostToRepoPage("/pulls/1/merge"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + With(Context{Payload: &Payload{"do": "merge"}}). + PostToRepoPage("/pulls/1/merge"). + ExpectStatus(http.StatusRequestEntityTooLarge) + }) +} + +func TestWebQuotaEnforcementRepoTransfer(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + t.Run("direct transfer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Trying to transfer the repository to a limited organization fails. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + With(Context{Payload: &Payload{ + "action": "transfer", + "repo_name": env.Users.Limited.Repo.FullName(), + "new_owner_name": env.Orgs.Limited.Org.Name, + }}). + PostToRepoPage("/settings"). + ExpectStatus(http.StatusOK). + ExpectFlashMessageContains("over quota", "The repository has not been transferred") + + // Trying to transfer to a different, also limited user, also fails. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + With(Context{Payload: &Payload{ + "action": "transfer", + "repo_name": env.Users.Limited.Repo.FullName(), + "new_owner_name": env.Users.Contributor.User.Name, + }}). + PostToRepoPage("/settings"). + ExpectStatus(http.StatusOK). + ExpectFlashMessageContains("over quota", "The repository has not been transferred") + }) + + t.Run("accept & reject", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Trying to transfer to a different user, with quota lifted, starts the transfer + env.As(t, env.Users.Contributor). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + env.As(ctx.t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + With(Context{Payload: &Payload{ + "action": "transfer", + "repo_name": env.Users.Limited.Repo.FullName(), + "new_owner_name": env.Users.Contributor.User.Name, + }}). + PostToRepoPage("/settings"). + ExpectStatus(http.StatusSeeOther). + ExpectFlashCookieContains("This repository has been marked for transfer and awaits confirmation") + }) + + // Trying to accept the transfer, with quota in effect, fails + env.As(t, env.Users.Contributor). + With(Context{Repo: env.Users.Limited.Repo}). + PostToRepoPage("/action/accept_transfer"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Rejecting the transfer, however, succeeds. + env.As(t, env.Users.Contributor). + With(Context{Repo: env.Users.Limited.Repo}). + PostToRepoPage("/action/reject_transfer"). + ExpectStatus(http.StatusSeeOther) + }) + }) +} + +func TestGitQuotaEnforcement(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + // Lets create a little helper that runs a task for three of our repos: the + // user's repo, the limited org repo, and the unlimited org's. + // + // We expect the last one to always work, and the expected status of the + // other two is decided by the caller. + runTestForAllRepos := func(t *testing.T, task func(t *testing.T, repo *repo_model.Repository) error, expectSuccess bool) { + t.Helper() + + err := task(t, env.Users.Limited.Repo) + if expectSuccess { + require.NoError(t, err) + } else { + require.Error(t, err) + } + + err = task(t, env.Orgs.Limited.Repo) + if expectSuccess { + require.NoError(t, err) + } else { + require.Error(t, err) + } + + err = task(t, env.Orgs.Unlimited.Repo) + require.NoError(t, err) + } + + // Run tests with quotas disabled + runTestForAllReposWithQuotaDisabled := func(t *testing.T, task func(t *testing.T, repo *repo_model.Repository) error) { + t.Helper() + + t.Run("with quota disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + runTestForAllRepos(t, task, true) + }) + } + + t.Run("push branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Pushing a new branch is denied if the user is over quota. + runTestForAllRepos(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Push("HEAD:new-branch") + }, false) + + // Pushing a new branch is always allowed if quota is disabled + runTestForAllReposWithQuotaDisabled(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Push("HEAD:new-branch-wo-quota") + }) + }) + + t.Run("push tag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Pushing a tag is denied if the user is over quota. + runTestForAllRepos(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Tag("new-tag"). + Push("new-tag") + }, false) + + // ...but succeeds if the quota feature is disabled + runTestForAllReposWithQuotaDisabled(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Tag("new-tag-wo-quota"). + Push("new-tag-wo-quota") + }) + }) + + t.Run("Agit PR", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Opening an Agit PR is *always* accepted. At least for now. + runTestForAllRepos(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Push("HEAD:refs/for/main/agit-pr-branch") + }, true) + }) + + t.Run("delete branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Deleting a branch is respected, and allowed. + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Push("HEAD:branch-to-delete") + require.NoError(ctx.t, err) + }). + Push(":branch-to-delete") + require.NoError(t, err) + }) + + t.Run("delete tag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Deleting a tag is always allowed. + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Tag("tag-to-delete"). + Push("tag-to-delete") + require.NoError(ctx.t, err) + }). + Push(":tag-to-delete") + require.NoError(t, err) + }) + + t.Run("mixed push", func(t *testing.T) { + t.Run("all deletes", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Pushing multiple deletes is allowed. + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Tag("mixed-push-tag"). + Push("mixed-push-tag", "HEAD:mixed-push-branch") + require.NoError(ctx.t, err) + }). + Push(":mixed-push-tag", ":mixed-push-branch") + require.NoError(t, err) + }) + + t.Run("new & delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Pushing a mix of deletions & a new branch is rejected together. + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Tag("mixed-push-tag"). + Push("mixed-push-tag", "HEAD:mixed-push-branch") + require.NoError(ctx.t, err) + }). + Push(":mixed-push-tag", ":mixed-push-branch", "HEAD:mixed-push-branch-new") + require.Error(t, err) + + // ...unless quota is disabled + t.Run("with quota disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Tag("mixed-push-tag-2"). + Push("mixed-push-tag-2", "HEAD:mixed-push-branch-2") + require.NoError(ctx.t, err) + }). + Push(":mixed-push-tag-2", ":mixed-push-branch-2", "HEAD:mixed-push-branch-new-2") + require.NoError(t, err) + }) + }) + }) + }) +} + +/********************** + * Here be dragons! * + * * + * . * + * .> )\;`a__ * + * ( _ _)/ /-." ~~ * + * `( )_ )/ * + * <_ <_ sb/dwb * + **********************/ + +type quotaWebEnv struct { + Users quotaWebEnvUsers + Orgs quotaWebEnvOrgs + + cleaners []func() +} + +type quotaWebEnvUsers struct { + Limited quotaWebEnvUser + Contributor quotaWebEnvUser +} + +type quotaWebEnvOrgs struct { + Limited quotaWebEnvOrg + Unlimited quotaWebEnvOrg +} + +type quotaWebEnvOrg struct { + Org *org_model.Organization + + Repo *repo_model.Repository + + QuotaGroup *quota_model.Group + QuotaRule *quota_model.Rule +} + +type quotaWebEnvUser struct { + User *user_model.User + Session *TestSession + Repo *repo_model.Repository + + QuotaGroup *quota_model.Group + QuotaRule *quota_model.Rule +} + +type Payload map[string]string + +type quotaWebEnvAsContext struct { + t *testing.T + + Doer *quotaWebEnvUser + Repo *repo_model.Repository + + Payload Payload + + CSRFPath *string + + gitPath string + + request *RequestWrapper + response *httptest.ResponseRecorder +} + +type Context struct { + Repo *repo_model.Repository + Payload *Payload + CSRFPath *string +} + +func (ctx *quotaWebEnvAsContext) With(opts Context) *quotaWebEnvAsContext { + if opts.Repo != nil { + ctx.Repo = opts.Repo + } + if opts.Payload != nil { + for key, value := range *opts.Payload { + ctx.Payload[key] = value + } + } + if opts.CSRFPath != nil { + ctx.CSRFPath = opts.CSRFPath + } + return ctx +} + +func (ctx *quotaWebEnvAsContext) VisitPage(page string) *quotaWebEnvAsContext { + ctx.t.Helper() + + ctx.request = NewRequest(ctx.t, "GET", page) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) VisitRepoPage(page string) *quotaWebEnvAsContext { + ctx.t.Helper() + + return ctx.VisitPage(ctx.Repo.Link() + page) +} + +func (ctx *quotaWebEnvAsContext) ExpectStatus(status int) *quotaWebEnvAsContext { + ctx.t.Helper() + + ctx.response = ctx.Doer.Session.MakeRequest(ctx.t, ctx.request, status) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) ExpectFlashMessage(value string) { + ctx.t.Helper() + + htmlDoc := NewHTMLParser(ctx.t, ctx.response.Body) + flashMessage := strings.TrimSpace(htmlDoc.Find(`.flash-message`).Text()) + + assert.EqualValues(ctx.t, value, flashMessage) +} + +func (ctx *quotaWebEnvAsContext) ExpectFlashMessageContains(parts ...string) { + ctx.t.Helper() + + htmlDoc := NewHTMLParser(ctx.t, ctx.response.Body) + flashMessage := strings.TrimSpace(htmlDoc.Find(`.flash-message`).Text()) + + for _, part := range parts { + assert.Contains(ctx.t, flashMessage, part) + } +} + +func (ctx *quotaWebEnvAsContext) ExpectFlashCookieContains(parts ...string) { + ctx.t.Helper() + + flashCookie := ctx.Doer.Session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(ctx.t, flashCookie) + + // Need to decode the cookie twice + flashValue, err := url.QueryUnescape(flashCookie.Value) + require.NoError(ctx.t, err) + flashValue, err = url.QueryUnescape(flashValue) + require.NoError(ctx.t, err) + + for _, part := range parts { + assert.Contains(ctx.t, flashValue, part) + } +} + +func (ctx *quotaWebEnvAsContext) ForkRepoInto(org quotaWebEnvOrg) { + ctx.t.Helper() + + ctx. + With(Context{Payload: &Payload{ + "uid": org.ID().AsString(), + "repo_name": ctx.Repo.Name + "-fork", + }}). + PostToRepoPage("/fork"). + ExpectStatus(http.StatusSeeOther) +} + +func (ctx *quotaWebEnvAsContext) CreatePullFrom(org quotaWebEnvOrg) { + ctx.t.Helper() + + url := fmt.Sprintf("/compare/main...%s:main", org.Org.Name) + ctx. + With(Context{Payload: &Payload{ + "title": "PR test", + }}). + PostToRepoPage(url). + ExpectStatus(http.StatusOK) +} + +func (ctx *quotaWebEnvAsContext) PostToPage(page string) *quotaWebEnvAsContext { + ctx.t.Helper() + + payload := ctx.Payload + csrfPath := page + if ctx.CSRFPath != nil { + csrfPath = *ctx.CSRFPath + } + + payload["_csrf"] = GetCSRF(ctx.t, ctx.Doer.Session, csrfPath) + + ctx.request = NewRequestWithValues(ctx.t, "POST", page, payload) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) PostToRepoPage(page string) *quotaWebEnvAsContext { + ctx.t.Helper() + + csrfPath := ctx.Repo.Link() + return ctx.With(Context{CSRFPath: &csrfPath}).PostToPage(ctx.Repo.Link() + page) +} + +func (ctx *quotaWebEnvAsContext) CreateAttachment(filename, attachmentType string) *quotaWebEnvAsContext { + ctx.t.Helper() + + body := &bytes.Buffer{} + image := generateImg() + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", filename) + require.NoError(ctx.t, err) + _, err = io.Copy(part, &image) + require.NoError(ctx.t, err) + err = writer.Close() + require.NoError(ctx.t, err) + + csrf := GetCSRF(ctx.t, ctx.Doer.Session, ctx.Repo.Link()) + + ctx.request = NewRequestWithBody(ctx.t, "POST", fmt.Sprintf("%s/%s/attachments", ctx.Repo.Link(), attachmentType), body) + ctx.request.Header.Add("X-Csrf-Token", csrf) + ctx.request.Header.Add("Content-Type", writer.FormDataContentType()) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) CreateIssueAttachment(filename string) *quotaWebEnvAsContext { + ctx.t.Helper() + + return ctx.CreateAttachment(filename, "issues") +} + +func (ctx *quotaWebEnvAsContext) CreateReleaseAttachment(filename string) *quotaWebEnvAsContext { + ctx.t.Helper() + + return ctx.CreateAttachment(filename, "releases") +} + +func (ctx *quotaWebEnvAsContext) WithoutQuota(task func(ctx *quotaWebEnvAsContext)) *quotaWebEnvAsContext { + ctx.t.Helper() + + defer ctx.Doer.SetQuota(-1)() + task(ctx) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) CreateMirror() *repo_model.Repository { + ctx.t.Helper() + + doer := ctx.Doer.User + + repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, doer, doer, repo_service.CreateRepoOptions{ + Name: "test-mirror", + IsMirror: true, + Status: repo_model.RepositoryBeingMigrated, + }) + require.NoError(ctx.t, err) + + return repo +} + +func (ctx *quotaWebEnvAsContext) LocalClone(u *url.URL) *quotaWebEnvAsContext { + ctx.t.Helper() + + gitPath := ctx.t.TempDir() + + doGitInitTestRepository(gitPath, git.Sha1ObjectFormat)(ctx.t) + + oldPath := u.Path + oldUser := u.User + defer func() { + u.Path = oldPath + u.User = oldUser + }() + u.Path = ctx.Repo.FullName() + ".git" + u.User = url.UserPassword(ctx.Doer.User.LowerName, userPassword) + + doGitAddRemote(gitPath, "origin", u)(ctx.t) + + ctx.gitPath = gitPath + + return ctx +} + +func (ctx *quotaWebEnvAsContext) Push(params ...string) error { + ctx.t.Helper() + + gitRepo, err := git.OpenRepository(git.DefaultContext, ctx.gitPath) + require.NoError(ctx.t, err) + defer gitRepo.Close() + + _, _, err = git.NewCommand(git.DefaultContext, "push", "origin"). + AddArguments(git.ToTrustedCmdArgs(params)...). + RunStdString(&git.RunOpts{Dir: ctx.gitPath}) + + return err +} + +func (ctx *quotaWebEnvAsContext) Tag(tagName string) *quotaWebEnvAsContext { + ctx.t.Helper() + + gitRepo, err := git.OpenRepository(git.DefaultContext, ctx.gitPath) + require.NoError(ctx.t, err) + defer gitRepo.Close() + + _, _, err = git.NewCommand(git.DefaultContext, "tag"). + AddArguments(git.ToTrustedCmdArgs([]string{tagName})...). + RunStdString(&git.RunOpts{Dir: ctx.gitPath}) + require.NoError(ctx.t, err) + + return ctx +} + +func (user *quotaWebEnvUser) SetQuota(limit int64) func() { + previousLimit := user.QuotaRule.Limit + + user.QuotaRule.Limit = limit + user.QuotaRule.Edit(db.DefaultContext, &limit, nil) + + return func() { + user.QuotaRule.Limit = previousLimit + user.QuotaRule.Edit(db.DefaultContext, &previousLimit, nil) + } +} + +func (user *quotaWebEnvUser) ID() convertAs { + return convertAs{ + asString: fmt.Sprintf("%d", user.User.ID), + } +} + +func (org *quotaWebEnvOrg) ID() convertAs { + return convertAs{ + asString: fmt.Sprintf("%d", org.Org.ID), + } +} + +type convertAs struct { + asString string +} + +func (cas convertAs) AsString() string { + return cas.asString +} + +func (env *quotaWebEnv) Cleanup() { + for i := len(env.cleaners) - 1; i >= 0; i-- { + env.cleaners[i]() + } +} + +func (env *quotaWebEnv) As(t *testing.T, user quotaWebEnvUser) *quotaWebEnvAsContext { + t.Helper() + + ctx := quotaWebEnvAsContext{ + t: t, + Doer: &user, + Repo: user.Repo, + + Payload: Payload{}, + } + return &ctx +} + +func (env *quotaWebEnv) RunVisitAndPostToRepoPageTests(t *testing.T, page string, payload *Payload, successStatus int) { + t.Helper() + + // Visiting the user's repo page fails due to being over quota. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + VisitRepoPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Posting as the limited user, to the limited repo, fails due to being over + // quota. + csrfPath := env.Users.Limited.Repo.Link() + env.As(t, env.Users.Limited). + With(Context{ + Payload: payload, + CSRFPath: &csrfPath, + Repo: env.Users.Limited.Repo, + }). + PostToRepoPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Visiting the limited org's repo page fails due to being over quota. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + VisitRepoPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Posting as the limited user, to a limited org's repo, fails for the same + // reason. + csrfPath = env.Orgs.Limited.Repo.Link() + env.As(t, env.Users.Limited). + With(Context{ + Payload: payload, + CSRFPath: &csrfPath, + Repo: env.Orgs.Limited.Repo, + }). + PostToRepoPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Visiting the repo page for the unlimited org succeeds. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Unlimited.Repo}). + VisitRepoPage(page). + ExpectStatus(http.StatusOK) + + // Posting as the limited user, to an unlimited org's repo, succeeds. + csrfPath = env.Orgs.Unlimited.Repo.Link() + env.As(t, env.Users.Limited). + With(Context{ + Payload: payload, + CSRFPath: &csrfPath, + Repo: env.Orgs.Unlimited.Repo, + }). + PostToRepoPage(page). + ExpectStatus(successStatus) +} + +func (env *quotaWebEnv) RunVisitAndPostToPageTests(t *testing.T, page string, payload *Payload, successStatus int) { + t.Helper() + + // Visiting the page is always fine. + env.As(t, env.Users.Limited). + VisitPage(page). + ExpectStatus(http.StatusOK) + + // Posting as the Limited user fails, because it is over quota. + env.As(t, env.Users.Limited). + With(Context{Payload: payload}). + With(Context{ + Payload: &Payload{ + "uid": env.Users.Limited.ID().AsString(), + }, + }). + PostToPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Posting to a limited org also fails, for the same reason. + env.As(t, env.Users.Limited). + With(Context{Payload: payload}). + With(Context{ + Payload: &Payload{ + "uid": env.Orgs.Limited.ID().AsString(), + }, + }). + PostToPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Posting to an unlimited repo works, however. + env.As(t, env.Users.Limited). + With(Context{Payload: payload}). + With(Context{ + Payload: &Payload{ + "uid": env.Orgs.Unlimited.ID().AsString(), + }, + }). + PostToPage(page). + ExpectStatus(successStatus) +} + +func createQuotaWebEnv(t *testing.T) *quotaWebEnv { + t.Helper() + + // *** helpers *** + + // Create a user, its quota group & rule + makeUser := func(t *testing.T, limit int64) quotaWebEnvUser { + t.Helper() + + user := quotaWebEnvUser{} + + // Create the user + userName := gouuid.NewString() + apiCreateUser(t, userName) + user.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: userName}) + user.Session = loginUser(t, userName) + + // Create a repository for the user + repo, _, _ := CreateDeclarativeRepoWithOptions(t, user.User, DeclarativeRepoOptions{}) + user.Repo = repo + + // Create a quota group for them + group, err := quota_model.CreateGroup(db.DefaultContext, userName) + require.NoError(t, err) + user.QuotaGroup = group + + // Create a rule + rule, err := quota_model.CreateRule(db.DefaultContext, userName, limit, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll}) + require.NoError(t, err) + user.QuotaRule = rule + + // Add the rule to the group + err = group.AddRuleByName(db.DefaultContext, rule.Name) + require.NoError(t, err) + + // Add the user to the group + err = group.AddUserByID(db.DefaultContext, user.User.ID) + require.NoError(t, err) + + return user + } + + // Create a user, its quota group & rule + makeOrg := func(t *testing.T, owner *user_model.User, limit int64) quotaWebEnvOrg { + t.Helper() + + org := quotaWebEnvOrg{} + + // Create the org + userName := gouuid.NewString() + org.Org = &org_model.Organization{ + Name: userName, + } + err := org_model.CreateOrganization(db.DefaultContext, org.Org, owner) + require.NoError(t, err) + + // Create a repository for the org + orgUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: org.Org.ID}) + repo, _, _ := CreateDeclarativeRepoWithOptions(t, orgUser, DeclarativeRepoOptions{}) + org.Repo = repo + + // Create a quota group for them + group, err := quota_model.CreateGroup(db.DefaultContext, userName) + require.NoError(t, err) + org.QuotaGroup = group + + // Create a rule + rule, err := quota_model.CreateRule(db.DefaultContext, userName, limit, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll}) + require.NoError(t, err) + org.QuotaRule = rule + + // Add the rule to the group + err = group.AddRuleByName(db.DefaultContext, rule.Name) + require.NoError(t, err) + + // Add the org to the group + err = group.AddUserByID(db.DefaultContext, org.Org.ID) + require.NoError(t, err) + + return org + } + + env := quotaWebEnv{} + env.cleaners = []func(){ + test.MockVariableValue(&setting.Quota.Enabled, true), + test.MockVariableValue(&testWebRoutes, routers.NormalRoutes()), + } + + // Create the limited user and the various orgs, and a contributor who's not + // in any of the orgs. + env.Users.Limited = makeUser(t, int64(0)) + env.Users.Contributor = makeUser(t, int64(0)) + env.Orgs.Limited = makeOrg(t, env.Users.Limited.User, int64(0)) + env.Orgs.Unlimited = makeOrg(t, env.Users.Limited.User, int64(-1)) + + return &env +}