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