Merge pull request 'Soft-quota foundations' (#4212) from algernon/forgejo:quota/helpers into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4212
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-08-02 12:27:45 +00:00
commit 63fdc1298f
61 changed files with 8638 additions and 72 deletions

View file

@ -76,6 +76,8 @@ var migrations = []*Migration{
NewMigration("Create the `following_repo` table", CreateFollowingRepoTable),
// v19 -> v20
NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable),
// v20 -> v21
NewMigration("Creating Quota-related tables", CreateQuotaTables),
}
// GetCurrentDBVersion returns the current Forgejo database version.

View file

@ -0,0 +1,52 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forgejo_migrations //nolint:revive
import "xorm.io/xorm"
type (
QuotaLimitSubject int
QuotaLimitSubjects []QuotaLimitSubject
QuotaKind int
)
type QuotaRule struct {
Name string `xorm:"pk not null"`
Limit int64 `xorm:"NOT NULL"`
Subjects QuotaLimitSubjects
}
type QuotaGroup struct {
Name string `xorm:"pk NOT NULL"`
}
type QuotaGroupRuleMapping struct {
ID int64 `xorm:"pk autoincr"`
GroupName string `xorm:"index unique(qgrm_gr) not null"`
RuleName string `xorm:"unique(qgrm_gr) not null"`
}
type QuotaGroupMapping struct {
ID int64 `xorm:"pk autoincr"`
Kind QuotaKind `xorm:"unique(qgm_kmg) not null"`
MappedID int64 `xorm:"unique(qgm_kmg) not null"`
GroupName string `xorm:"index unique(qgm_kmg) not null"`
}
func CreateQuotaTables(x *xorm.Engine) error {
if err := x.Sync(new(QuotaRule)); err != nil {
return err
}
if err := x.Sync(new(QuotaGroup)); err != nil {
return err
}
if err := x.Sync(new(QuotaGroupRuleMapping)); err != nil {
return err
}
return x.Sync(new(QuotaGroupMapping))
}

127
models/quota/errors.go Normal file
View file

@ -0,0 +1,127 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import "fmt"
type ErrRuleAlreadyExists struct {
Name string
}
func IsErrRuleAlreadyExists(err error) bool {
_, ok := err.(ErrRuleAlreadyExists)
return ok
}
func (err ErrRuleAlreadyExists) Error() string {
return fmt.Sprintf("rule already exists: [name: %s]", err.Name)
}
type ErrRuleNotFound struct {
Name string
}
func IsErrRuleNotFound(err error) bool {
_, ok := err.(ErrRuleNotFound)
return ok
}
func (err ErrRuleNotFound) Error() string {
return fmt.Sprintf("rule not found: [name: %s]", err.Name)
}
type ErrGroupAlreadyExists struct {
Name string
}
func IsErrGroupAlreadyExists(err error) bool {
_, ok := err.(ErrGroupAlreadyExists)
return ok
}
func (err ErrGroupAlreadyExists) Error() string {
return fmt.Sprintf("group already exists: [name: %s]", err.Name)
}
type ErrGroupNotFound struct {
Name string
}
func IsErrGroupNotFound(err error) bool {
_, ok := err.(ErrGroupNotFound)
return ok
}
func (err ErrGroupNotFound) Error() string {
return fmt.Sprintf("group not found: [group: %s]", err.Name)
}
type ErrUserAlreadyInGroup struct {
GroupName string
UserID int64
}
func IsErrUserAlreadyInGroup(err error) bool {
_, ok := err.(ErrUserAlreadyInGroup)
return ok
}
func (err ErrUserAlreadyInGroup) Error() string {
return fmt.Sprintf("user already in group: [group: %s, userID: %d]", err.GroupName, err.UserID)
}
type ErrUserNotInGroup struct {
GroupName string
UserID int64
}
func IsErrUserNotInGroup(err error) bool {
_, ok := err.(ErrUserNotInGroup)
return ok
}
func (err ErrUserNotInGroup) Error() string {
return fmt.Sprintf("user not in group: [group: %s, userID: %d]", err.GroupName, err.UserID)
}
type ErrRuleAlreadyInGroup struct {
GroupName string
RuleName string
}
func IsErrRuleAlreadyInGroup(err error) bool {
_, ok := err.(ErrRuleAlreadyInGroup)
return ok
}
func (err ErrRuleAlreadyInGroup) Error() string {
return fmt.Sprintf("rule already in group: [group: %s, rule: %s]", err.GroupName, err.RuleName)
}
type ErrRuleNotInGroup struct {
GroupName string
RuleName string
}
func IsErrRuleNotInGroup(err error) bool {
_, ok := err.(ErrRuleNotInGroup)
return ok
}
func (err ErrRuleNotInGroup) Error() string {
return fmt.Sprintf("rule not in group: [group: %s, rule: %s]", err.GroupName, err.RuleName)
}
type ErrParseLimitSubjectUnrecognized struct {
Subject string
}
func IsErrParseLimitSubjectUnrecognized(err error) bool {
_, ok := err.(ErrParseLimitSubjectUnrecognized)
return ok
}
func (err ErrParseLimitSubjectUnrecognized) Error() string {
return fmt.Sprintf("unrecognized quota limit subject: [subject: %s]", err.Subject)
}

401
models/quota/group.go Normal file
View file

@ -0,0 +1,401 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import (
"context"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"xorm.io/builder"
)
type (
GroupList []*Group
Group struct {
// Name of the quota group
Name string `json:"name" xorm:"pk NOT NULL" binding:"Required"`
Rules []Rule `json:"rules" xorm:"-"`
}
)
type GroupRuleMapping struct {
ID int64 `xorm:"pk autoincr" json:"-"`
GroupName string `xorm:"index unique(qgrm_gr) not null" json:"group_name"`
RuleName string `xorm:"unique(qgrm_gr) not null" json:"rule_name"`
}
type Kind int
const (
KindUser Kind = iota
)
type GroupMapping struct {
ID int64 `xorm:"pk autoincr"`
Kind Kind `xorm:"unique(qgm_kmg) not null"`
MappedID int64 `xorm:"unique(qgm_kmg) not null"`
GroupName string `xorm:"index unique(qgm_kmg) not null"`
}
func (g *Group) TableName() string {
return "quota_group"
}
func (grm *GroupRuleMapping) TableName() string {
return "quota_group_rule_mapping"
}
func (ugm *GroupMapping) TableName() string {
return "quota_group_mapping"
}
func (g *Group) LoadRules(ctx context.Context) error {
return db.GetEngine(ctx).Select("`quota_rule`.*").
Table("quota_rule").
Join("INNER", "`quota_group_rule_mapping`", "`quota_group_rule_mapping`.rule_name = `quota_rule`.name").
Where("`quota_group_rule_mapping`.group_name = ?", g.Name).
Find(&g.Rules)
}
func (g *Group) isUserInGroup(ctx context.Context, userID int64) (bool, error) {
return db.GetEngine(ctx).
Where("kind = ? AND mapped_id = ? AND group_name = ?", KindUser, userID, g.Name).
Get(&GroupMapping{})
}
func (g *Group) AddUserByID(ctx context.Context, userID int64) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
exists, err := g.isUserInGroup(ctx, userID)
if err != nil {
return err
} else if exists {
return ErrUserAlreadyInGroup{GroupName: g.Name, UserID: userID}
}
_, err = db.GetEngine(ctx).Insert(&GroupMapping{
Kind: KindUser,
MappedID: userID,
GroupName: g.Name,
})
if err != nil {
return err
}
return committer.Commit()
}
func (g *Group) RemoveUserByID(ctx context.Context, userID int64) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
exists, err := g.isUserInGroup(ctx, userID)
if err != nil {
return err
} else if !exists {
return ErrUserNotInGroup{GroupName: g.Name, UserID: userID}
}
_, err = db.GetEngine(ctx).Delete(&GroupMapping{
Kind: KindUser,
MappedID: userID,
GroupName: g.Name,
})
if err != nil {
return err
}
return committer.Commit()
}
func (g *Group) isRuleInGroup(ctx context.Context, ruleName string) (bool, error) {
return db.GetEngine(ctx).
Where("group_name = ? AND rule_name = ?", g.Name, ruleName).
Get(&GroupRuleMapping{})
}
func (g *Group) AddRuleByName(ctx context.Context, ruleName string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
exists, err := DoesRuleExist(ctx, ruleName)
if err != nil {
return err
} else if !exists {
return ErrRuleNotFound{Name: ruleName}
}
has, err := g.isRuleInGroup(ctx, ruleName)
if err != nil {
return err
} else if has {
return ErrRuleAlreadyInGroup{GroupName: g.Name, RuleName: ruleName}
}
_, err = db.GetEngine(ctx).Insert(&GroupRuleMapping{
GroupName: g.Name,
RuleName: ruleName,
})
if err != nil {
return err
}
return committer.Commit()
}
func (g *Group) RemoveRuleByName(ctx context.Context, ruleName string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
exists, err := g.isRuleInGroup(ctx, ruleName)
if err != nil {
return err
} else if !exists {
return ErrRuleNotInGroup{GroupName: g.Name, RuleName: ruleName}
}
_, err = db.GetEngine(ctx).Delete(&GroupRuleMapping{
GroupName: g.Name,
RuleName: ruleName,
})
if err != nil {
return err
}
return committer.Commit()
}
var affectsMap = map[LimitSubject]LimitSubjects{
LimitSubjectSizeAll: {
LimitSubjectSizeReposAll,
LimitSubjectSizeGitLFS,
LimitSubjectSizeAssetsAll,
},
LimitSubjectSizeReposAll: {
LimitSubjectSizeReposPublic,
LimitSubjectSizeReposPrivate,
},
LimitSubjectSizeAssetsAll: {
LimitSubjectSizeAssetsAttachmentsAll,
LimitSubjectSizeAssetsArtifacts,
LimitSubjectSizeAssetsPackagesAll,
},
LimitSubjectSizeAssetsAttachmentsAll: {
LimitSubjectSizeAssetsAttachmentsIssues,
LimitSubjectSizeAssetsAttachmentsReleases,
},
}
func (g *Group) Evaluate(used Used, forSubject LimitSubject) (bool, bool) {
var found bool
for _, rule := range g.Rules {
ok, has := rule.Evaluate(used, forSubject)
if has {
found = true
if !ok {
return false, true
}
}
}
if !found {
// If Evaluation for forSubject did not succeed, try evaluating against
// subjects below
for _, subject := range affectsMap[forSubject] {
ok, has := g.Evaluate(used, subject)
if has {
found = true
if !ok {
return false, true
}
}
}
}
return true, found
}
func (gl *GroupList) Evaluate(used Used, forSubject LimitSubject) bool {
// If there are no groups, default to success:
if gl == nil || len(*gl) == 0 {
return true
}
for _, group := range *gl {
ok, has := group.Evaluate(used, forSubject)
if has && ok {
return true
}
}
return false
}
func GetGroupByName(ctx context.Context, name string) (*Group, error) {
var group Group
has, err := db.GetEngine(ctx).Where("name = ?", name).Get(&group)
if has {
if err = group.LoadRules(ctx); err != nil {
return nil, err
}
return &group, nil
}
return nil, err
}
func ListGroups(ctx context.Context) (GroupList, error) {
var groups GroupList
err := db.GetEngine(ctx).Find(&groups)
return groups, err
}
func doesGroupExist(ctx context.Context, name string) (bool, error) {
return db.GetEngine(ctx).Where("name = ?", name).Get(&Group{})
}
func CreateGroup(ctx context.Context, name string) (*Group, error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
defer committer.Close()
exists, err := doesGroupExist(ctx, name)
if err != nil {
return nil, err
} else if exists {
return nil, ErrGroupAlreadyExists{Name: name}
}
group := Group{Name: name}
_, err = db.GetEngine(ctx).Insert(group)
if err != nil {
return nil, err
}
return &group, committer.Commit()
}
func ListUsersInGroup(ctx context.Context, name string) ([]*user_model.User, error) {
group, err := GetGroupByName(ctx, name)
if err != nil {
return nil, err
}
var users []*user_model.User
err = db.GetEngine(ctx).Select("`user`.*").
Table("user").
Join("INNER", "`quota_group_mapping`", "`quota_group_mapping`.mapped_id = `user`.id").
Where("`quota_group_mapping`.kind = ? AND `quota_group_mapping`.group_name = ?", KindUser, group.Name).
Find(&users)
return users, err
}
func DeleteGroupByName(ctx context.Context, name string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
_, err = db.GetEngine(ctx).Delete(GroupMapping{
GroupName: name,
})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).Delete(GroupRuleMapping{
GroupName: name,
})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).Delete(Group{Name: name})
if err != nil {
return err
}
return committer.Commit()
}
func SetUserGroups(ctx context.Context, userID int64, groups *[]string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
// First: remove the user from any groups
_, err = db.GetEngine(ctx).Where("kind = ? AND mapped_id = ?", KindUser, userID).Delete(GroupMapping{})
if err != nil {
return err
}
if groups == nil {
return nil
}
// Then add the user to each group listed
for _, groupName := range *groups {
group, err := GetGroupByName(ctx, groupName)
if err != nil {
return err
}
if group == nil {
return ErrGroupNotFound{Name: groupName}
}
err = group.AddUserByID(ctx, userID)
if err != nil {
return err
}
}
return committer.Commit()
}
func GetGroupsForUser(ctx context.Context, userID int64) (GroupList, error) {
var groups GroupList
err := db.GetEngine(ctx).
Where(builder.In("name",
builder.Select("group_name").
From("quota_group_mapping").
Where(builder.And(
builder.Eq{"kind": KindUser},
builder.Eq{"mapped_id": userID}),
))).
Find(&groups)
if err != nil {
return nil, err
}
if len(groups) == 0 {
err = db.GetEngine(ctx).Where(builder.In("name", setting.Quota.DefaultGroups)).Find(&groups)
if err != nil {
return nil, err
}
if len(groups) == 0 {
return nil, nil
}
}
for _, group := range groups {
err = group.LoadRules(ctx)
if err != nil {
return nil, err
}
}
return groups, nil
}

View file

@ -0,0 +1,69 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import "fmt"
type (
LimitSubject int
LimitSubjects []LimitSubject
)
const (
LimitSubjectNone LimitSubject = iota
LimitSubjectSizeAll
LimitSubjectSizeReposAll
LimitSubjectSizeReposPublic
LimitSubjectSizeReposPrivate
LimitSubjectSizeGitAll
LimitSubjectSizeGitLFS
LimitSubjectSizeAssetsAll
LimitSubjectSizeAssetsAttachmentsAll
LimitSubjectSizeAssetsAttachmentsIssues
LimitSubjectSizeAssetsAttachmentsReleases
LimitSubjectSizeAssetsArtifacts
LimitSubjectSizeAssetsPackagesAll
LimitSubjectSizeWiki
LimitSubjectFirst = LimitSubjectSizeAll
LimitSubjectLast = LimitSubjectSizeWiki
)
var limitSubjectRepr = map[string]LimitSubject{
"none": LimitSubjectNone,
"size:all": LimitSubjectSizeAll,
"size:repos:all": LimitSubjectSizeReposAll,
"size:repos:public": LimitSubjectSizeReposPublic,
"size:repos:private": LimitSubjectSizeReposPrivate,
"size:git:all": LimitSubjectSizeGitAll,
"size:git:lfs": LimitSubjectSizeGitLFS,
"size:assets:all": LimitSubjectSizeAssetsAll,
"size:assets:attachments:all": LimitSubjectSizeAssetsAttachmentsAll,
"size:assets:attachments:issues": LimitSubjectSizeAssetsAttachmentsIssues,
"size:assets:attachments:releases": LimitSubjectSizeAssetsAttachmentsReleases,
"size:assets:artifacts": LimitSubjectSizeAssetsArtifacts,
"size:assets:packages:all": LimitSubjectSizeAssetsPackagesAll,
"size:assets:wiki": LimitSubjectSizeWiki,
}
func (subject LimitSubject) String() string {
for repr, limit := range limitSubjectRepr {
if limit == subject {
return repr
}
}
return "<unknown>"
}
func (subjects LimitSubjects) GoString() string {
return fmt.Sprintf("%T{%+v}", subjects, subjects)
}
func ParseLimitSubject(repr string) (LimitSubject, error) {
result, has := limitSubjectRepr[repr]
if !has {
return LimitSubjectNone, ErrParseLimitSubjectUnrecognized{Subject: repr}
}
return result, nil
}

36
models/quota/quota.go Normal file
View file

@ -0,0 +1,36 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/setting"
)
func init() {
db.RegisterModel(new(Rule))
db.RegisterModel(new(Group))
db.RegisterModel(new(GroupRuleMapping))
db.RegisterModel(new(GroupMapping))
}
func EvaluateForUser(ctx context.Context, userID int64, subject LimitSubject) (bool, error) {
if !setting.Quota.Enabled {
return true, nil
}
groups, err := GetGroupsForUser(ctx, userID)
if err != nil {
return false, err
}
used, err := GetUsedForUser(ctx, userID)
if err != nil {
return false, err
}
return groups.Evaluate(*used, subject), nil
}

View file

@ -0,0 +1,208 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota_test
import (
"testing"
quota_model "code.gitea.io/gitea/models/quota"
"github.com/stretchr/testify/assert"
)
func TestQuotaGroupAllRulesMustPass(t *testing.T) {
unlimitedRule := quota_model.Rule{
Limit: -1,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
group := quota_model.Group{
Rules: []quota_model.Rule{
unlimitedRule,
denyRule,
},
}
used := quota_model.Used{}
used.Size.Repos.Public = 1024
// Within a group, *all* rules must pass. Thus, if we have a deny-all rule,
// and an unlimited rule, that will always fail.
ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, has)
assert.False(t, ok)
}
func TestQuotaGroupRuleScenario1(t *testing.T) {
group := quota_model.Group{
Rules: []quota_model.Rule{
{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAssetsAttachmentsReleases,
quota_model.LimitSubjectSizeGitLFS,
quota_model.LimitSubjectSizeAssetsPackagesAll,
},
},
{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeGitLFS,
},
},
},
}
used := quota_model.Used{}
used.Size.Assets.Attachments.Releases = 512
used.Size.Assets.Packages.All = 256
used.Size.Git.LFS = 16
ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeAssetsAttachmentsReleases)
assert.True(t, has, "size:assets:attachments:releases is covered")
assert.True(t, ok, "size:assets:attachments:releases passes")
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
assert.True(t, has, "size:assets:packages:all is covered")
assert.True(t, ok, "size:assets:packages:all passes")
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
assert.True(t, has, "size:git:lfs is covered")
assert.False(t, ok, "size:git:lfs fails")
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, has, "size:all is covered")
assert.False(t, ok, "size:all fails")
}
func TestQuotaGroupRuleCombination(t *testing.T) {
repoRule := quota_model.Rule{
Limit: 4096,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeReposAll,
},
}
packagesRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAssetsPackagesAll,
},
}
used := quota_model.Used{}
used.Size.Repos.Public = 1024
used.Size.Assets.Packages.All = 1024
group := quota_model.Group{
Rules: []quota_model.Rule{
repoRule,
packagesRule,
},
}
// Git LFS isn't covered by any rule
_, has := group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
assert.False(t, has)
// repos:all is covered, and is passing
ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeReposAll)
assert.True(t, has)
assert.True(t, ok)
// packages:all is covered, and is failing
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
assert.True(t, has)
assert.False(t, ok)
// size:all is covered, and is failing (due to packages:all being over quota)
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, has, "size:all should be covered")
assert.False(t, ok, "size:all should fail")
}
func TestQuotaGroupListsRequireOnlyOnePassing(t *testing.T) {
unlimitedRule := quota_model.Rule{
Limit: -1,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyGroup := quota_model.Group{
Rules: []quota_model.Rule{
denyRule,
},
}
unlimitedGroup := quota_model.Group{
Rules: []quota_model.Rule{
unlimitedRule,
},
}
groups := quota_model.GroupList{&denyGroup, &unlimitedGroup}
used := quota_model.Used{}
used.Size.Repos.Public = 1024
// In a group list, if any group passes, the entire evaluation passes.
ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, ok)
}
func TestQuotaGroupListAllFailing(t *testing.T) {
denyRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
limitedRule := quota_model.Rule{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyGroup := quota_model.Group{
Rules: []quota_model.Rule{
denyRule,
},
}
limitedGroup := quota_model.Group{
Rules: []quota_model.Rule{
limitedRule,
},
}
groups := quota_model.GroupList{&denyGroup, &limitedGroup}
used := quota_model.Used{}
used.Size.Repos.Public = 2048
ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.False(t, ok)
}
func TestQuotaGroupListEmpty(t *testing.T) {
groups := quota_model.GroupList{}
used := quota_model.Used{}
used.Size.Repos.Public = 2048
ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, ok)
}

View file

@ -0,0 +1,304 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota_test
import (
"testing"
quota_model "code.gitea.io/gitea/models/quota"
"github.com/stretchr/testify/assert"
)
func makeFullyUsed() quota_model.Used {
return quota_model.Used{
Size: quota_model.UsedSize{
Repos: quota_model.UsedSizeRepos{
Public: 1024,
Private: 1024,
},
Git: quota_model.UsedSizeGit{
LFS: 1024,
},
Assets: quota_model.UsedSizeAssets{
Attachments: quota_model.UsedSizeAssetsAttachments{
Issues: 1024,
Releases: 1024,
},
Artifacts: 1024,
Packages: quota_model.UsedSizeAssetsPackages{
All: 1024,
},
},
},
}
}
func makePartiallyUsed() quota_model.Used {
return quota_model.Used{
Size: quota_model.UsedSize{
Repos: quota_model.UsedSizeRepos{
Public: 1024,
},
Assets: quota_model.UsedSizeAssets{
Attachments: quota_model.UsedSizeAssetsAttachments{
Releases: 1024,
},
},
},
}
}
func setUsed(used quota_model.Used, subject quota_model.LimitSubject, value int64) *quota_model.Used {
switch subject {
case quota_model.LimitSubjectSizeReposPublic:
used.Size.Repos.Public = value
return &used
case quota_model.LimitSubjectSizeReposPrivate:
used.Size.Repos.Private = value
return &used
case quota_model.LimitSubjectSizeGitLFS:
used.Size.Git.LFS = value
return &used
case quota_model.LimitSubjectSizeAssetsAttachmentsIssues:
used.Size.Assets.Attachments.Issues = value
return &used
case quota_model.LimitSubjectSizeAssetsAttachmentsReleases:
used.Size.Assets.Attachments.Releases = value
return &used
case quota_model.LimitSubjectSizeAssetsArtifacts:
used.Size.Assets.Artifacts = value
return &used
case quota_model.LimitSubjectSizeAssetsPackagesAll:
used.Size.Assets.Packages.All = value
return &used
case quota_model.LimitSubjectSizeWiki:
}
return nil
}
func assertEvaluation(t *testing.T, rule quota_model.Rule, used quota_model.Used, subject quota_model.LimitSubject, expected bool) {
t.Helper()
t.Run(subject.String(), func(t *testing.T) {
ok, has := rule.Evaluate(used, subject)
assert.True(t, has)
assert.Equal(t, expected, ok)
})
}
func TestQuotaRuleNoEvaluation(t *testing.T) {
rule := quota_model.Rule{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAssetsAttachmentsAll,
},
}
used := quota_model.Used{}
used.Size.Repos.Public = 4096
_, has := rule.Evaluate(used, quota_model.LimitSubjectSizeReposAll)
// We have a rule for "size:assets:attachments:all", and query for
// "size:repos:all". We don't cover that subject, so the evaluation returns
// with no rules found.
assert.False(t, has)
}
func TestQuotaRuleDirectEvaluation(t *testing.T) {
// This function is meant to test direct rule evaluation: cases where we set
// a rule for a subject, and we evaluate against the same subject.
runTest := func(t *testing.T, subject quota_model.LimitSubject, limit, used int64, expected bool) {
t.Helper()
rule := quota_model.Rule{
Limit: limit,
Subjects: quota_model.LimitSubjects{
subject,
},
}
usedObj := setUsed(quota_model.Used{}, subject, used)
if usedObj == nil {
return
}
assertEvaluation(t, rule, *usedObj, subject, expected)
}
t.Run("limit:0", func(t *testing.T) {
// With limit:0, nothing used is fine.
t.Run("used:0", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, 0, 0, true)
}
})
// With limit:0, any usage will fail evaluation
t.Run("used:512", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, 0, 512, false)
}
})
})
t.Run("limit:unlimited", func(t *testing.T) {
// With no limits, any usage will succeed evaluation
t.Run("used:512", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, -1, 512, true)
}
})
})
t.Run("limit:1024", func(t *testing.T) {
// With a set limit, usage below the limit succeeds
t.Run("used:512", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, 1024, 512, true)
}
})
// With a set limit, usage above the limit fails
t.Run("used:2048", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, 1024, 2048, false)
}
})
})
}
func TestQuotaRuleCombined(t *testing.T) {
rule := quota_model.Rule{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeGitLFS,
quota_model.LimitSubjectSizeAssetsAttachmentsReleases,
quota_model.LimitSubjectSizeAssetsPackagesAll,
},
}
used := quota_model.Used{
Size: quota_model.UsedSize{
Repos: quota_model.UsedSizeRepos{
Public: 4096,
},
Git: quota_model.UsedSizeGit{
LFS: 256,
},
Assets: quota_model.UsedSizeAssets{
Attachments: quota_model.UsedSizeAssetsAttachments{
Issues: 2048,
Releases: 256,
},
Packages: quota_model.UsedSizeAssetsPackages{
All: 2560,
},
},
},
}
expectationMap := map[quota_model.LimitSubject]bool{
quota_model.LimitSubjectSizeGitLFS: false,
quota_model.LimitSubjectSizeAssetsAttachmentsReleases: false,
quota_model.LimitSubjectSizeAssetsPackagesAll: false,
}
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
t.Run(subject.String(), func(t *testing.T) {
evalOk, evalHas := rule.Evaluate(used, subject)
expected, expectedHas := expectationMap[subject]
assert.Equal(t, expectedHas, evalHas)
if expectedHas {
assert.Equal(t, expected, evalOk)
}
})
}
}
func TestQuotaRuleSizeAll(t *testing.T) {
runTests := func(t *testing.T, rule quota_model.Rule, expected bool) {
t.Helper()
subject := quota_model.LimitSubjectSizeAll
t.Run("used:0", func(t *testing.T) {
used := quota_model.Used{}
assertEvaluation(t, rule, used, subject, true)
})
t.Run("used:some-each", func(t *testing.T) {
used := makeFullyUsed()
assertEvaluation(t, rule, used, subject, expected)
})
t.Run("used:some", func(t *testing.T) {
used := makePartiallyUsed()
assertEvaluation(t, rule, used, subject, expected)
})
}
// With all limits set to 0, evaluation always fails if usage > 0
t.Run("rule:0", func(t *testing.T) {
rule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
runTests(t, rule, false)
})
// With no limits, evaluation always succeeds
t.Run("rule:unlimited", func(t *testing.T) {
rule := quota_model.Rule{
Limit: -1,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
runTests(t, rule, true)
})
// With a specific, very generous limit, evaluation succeeds if the limit isn't exhausted
t.Run("rule:generous", func(t *testing.T) {
rule := quota_model.Rule{
Limit: 102400,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
runTests(t, rule, true)
t.Run("limit exhaustion", func(t *testing.T) {
used := quota_model.Used{
Size: quota_model.UsedSize{
Repos: quota_model.UsedSizeRepos{
Public: 204800,
},
},
}
assertEvaluation(t, rule, used, quota_model.LimitSubjectSizeAll, false)
})
})
// With a specific, small limit, evaluation fails
t.Run("rule:limited", func(t *testing.T) {
rule := quota_model.Rule{
Limit: 512,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
runTests(t, rule, false)
})
}

127
models/quota/rule.go Normal file
View file

@ -0,0 +1,127 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import (
"context"
"slices"
"code.gitea.io/gitea/models/db"
)
type Rule struct {
Name string `xorm:"pk not null" json:"name,omitempty"`
Limit int64 `xorm:"NOT NULL" binding:"Required" json:"limit"`
Subjects LimitSubjects `json:"subjects,omitempty"`
}
func (r *Rule) TableName() string {
return "quota_rule"
}
func (r Rule) Evaluate(used Used, forSubject LimitSubject) (bool, bool) {
// If there's no limit, short circuit out
if r.Limit == -1 {
return true, true
}
// If the rule does not cover forSubject, bail out early
if !slices.Contains(r.Subjects, forSubject) {
return false, false
}
var sum int64
for _, subject := range r.Subjects {
sum += used.CalculateFor(subject)
}
return sum <= r.Limit, true
}
func (r *Rule) Edit(ctx context.Context, limit *int64, subjects *LimitSubjects) (*Rule, error) {
cols := []string{}
if limit != nil {
r.Limit = *limit
cols = append(cols, "limit")
}
if subjects != nil {
r.Subjects = *subjects
cols = append(cols, "subjects")
}
_, err := db.GetEngine(ctx).Where("name = ?", r.Name).Cols(cols...).Update(r)
return r, err
}
func GetRuleByName(ctx context.Context, name string) (*Rule, error) {
var rule Rule
has, err := db.GetEngine(ctx).Where("name = ?", name).Get(&rule)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return &rule, err
}
func ListRules(ctx context.Context) ([]Rule, error) {
var rules []Rule
err := db.GetEngine(ctx).Find(&rules)
return rules, err
}
func DoesRuleExist(ctx context.Context, name string) (bool, error) {
return db.GetEngine(ctx).
Where("name = ?", name).
Get(&Rule{})
}
func CreateRule(ctx context.Context, name string, limit int64, subjects LimitSubjects) (*Rule, error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
defer committer.Close()
exists, err := DoesRuleExist(ctx, name)
if err != nil {
return nil, err
} else if exists {
return nil, ErrRuleAlreadyExists{Name: name}
}
rule := Rule{
Name: name,
Limit: limit,
Subjects: subjects,
}
_, err = db.GetEngine(ctx).Insert(rule)
if err != nil {
return nil, err
}
return &rule, committer.Commit()
}
func DeleteRuleByName(ctx context.Context, name string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
_, err = db.GetEngine(ctx).Delete(GroupRuleMapping{
RuleName: name,
})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).Delete(Rule{Name: name})
if err != nil {
return err
}
return committer.Commit()
}

252
models/quota/used.go Normal file
View file

@ -0,0 +1,252 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import (
"context"
action_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
package_model "code.gitea.io/gitea/models/packages"
repo_model "code.gitea.io/gitea/models/repo"
"xorm.io/builder"
)
type Used struct {
Size UsedSize
}
type UsedSize struct {
Repos UsedSizeRepos
Git UsedSizeGit
Assets UsedSizeAssets
}
func (u UsedSize) All() int64 {
return u.Repos.All() + u.Git.All(u.Repos) + u.Assets.All()
}
type UsedSizeRepos struct {
Public int64
Private int64
}
func (u UsedSizeRepos) All() int64 {
return u.Public + u.Private
}
type UsedSizeGit struct {
LFS int64
}
func (u UsedSizeGit) All(r UsedSizeRepos) int64 {
return u.LFS + r.All()
}
type UsedSizeAssets struct {
Attachments UsedSizeAssetsAttachments
Artifacts int64
Packages UsedSizeAssetsPackages
}
func (u UsedSizeAssets) All() int64 {
return u.Attachments.All() + u.Artifacts + u.Packages.All
}
type UsedSizeAssetsAttachments struct {
Issues int64
Releases int64
}
func (u UsedSizeAssetsAttachments) All() int64 {
return u.Issues + u.Releases
}
type UsedSizeAssetsPackages struct {
All int64
}
func (u Used) CalculateFor(subject LimitSubject) int64 {
switch subject {
case LimitSubjectNone:
return 0
case LimitSubjectSizeAll:
return u.Size.All()
case LimitSubjectSizeReposAll:
return u.Size.Repos.All()
case LimitSubjectSizeReposPublic:
return u.Size.Repos.Public
case LimitSubjectSizeReposPrivate:
return u.Size.Repos.Private
case LimitSubjectSizeGitAll:
return u.Size.Git.All(u.Size.Repos)
case LimitSubjectSizeGitLFS:
return u.Size.Git.LFS
case LimitSubjectSizeAssetsAll:
return u.Size.Assets.All()
case LimitSubjectSizeAssetsAttachmentsAll:
return u.Size.Assets.Attachments.All()
case LimitSubjectSizeAssetsAttachmentsIssues:
return u.Size.Assets.Attachments.Issues
case LimitSubjectSizeAssetsAttachmentsReleases:
return u.Size.Assets.Attachments.Releases
case LimitSubjectSizeAssetsArtifacts:
return u.Size.Assets.Artifacts
case LimitSubjectSizeAssetsPackagesAll:
return u.Size.Assets.Packages.All
case LimitSubjectSizeWiki:
return 0
}
return 0
}
func makeUserOwnedCondition(q string, userID int64) builder.Cond {
switch q {
case "repositories", "attachments", "artifacts":
return builder.Eq{"`repository`.owner_id": userID}
case "packages":
return builder.Or(
builder.Eq{"`repository`.owner_id": userID},
builder.And(
builder.Eq{"`package`.repo_id": 0},
builder.Eq{"`package`.owner_id": userID},
),
)
}
return builder.NewCond()
}
func createQueryFor(ctx context.Context, userID int64, q string) db.Engine {
session := db.GetEngine(ctx)
switch q {
case "repositories":
session = session.Table("repository")
case "attachments":
session = session.
Table("attachment").
Join("INNER", "`repository`", "`attachment`.repo_id = `repository`.id")
case "artifacts":
session = session.
Table("action_artifact").
Join("INNER", "`repository`", "`action_artifact`.repo_id = `repository`.id")
case "packages":
session = session.
Table("package_version").
Join("INNER", "`package_file`", "`package_file`.version_id = `package_version`.id").
Join("INNER", "`package_blob`", "`package_file`.blob_id = `package_blob`.id").
Join("INNER", "`package`", "`package_version`.package_id = `package`.id").
Join("LEFT OUTER", "`repository`", "`package`.repo_id = `repository`.id")
}
return session.Where(makeUserOwnedCondition(q, userID))
}
func GetQuotaAttachmentsForUser(ctx context.Context, userID int64, opts db.ListOptions) (int64, *[]*repo_model.Attachment, error) {
var attachments []*repo_model.Attachment
sess := createQueryFor(ctx, userID, "attachments").
OrderBy("`attachment`.size DESC")
if opts.PageSize > 0 {
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
}
count, err := sess.FindAndCount(&attachments)
if err != nil {
return 0, nil, err
}
return count, &attachments, nil
}
func GetQuotaPackagesForUser(ctx context.Context, userID int64, opts db.ListOptions) (int64, *[]*package_model.PackageVersion, error) {
var pkgs []*package_model.PackageVersion
sess := createQueryFor(ctx, userID, "packages").
OrderBy("`package_blob`.size DESC")
if opts.PageSize > 0 {
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
}
count, err := sess.FindAndCount(&pkgs)
if err != nil {
return 0, nil, err
}
return count, &pkgs, nil
}
func GetQuotaArtifactsForUser(ctx context.Context, userID int64, opts db.ListOptions) (int64, *[]*action_model.ActionArtifact, error) {
var artifacts []*action_model.ActionArtifact
sess := createQueryFor(ctx, userID, "artifacts").
OrderBy("`action_artifact`.file_compressed_size DESC")
if opts.PageSize > 0 {
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
}
count, err := sess.FindAndCount(&artifacts)
if err != nil {
return 0, nil, err
}
return count, &artifacts, nil
}
func GetUsedForUser(ctx context.Context, userID int64) (*Used, error) {
var used Used
_, err := createQueryFor(ctx, userID, "repositories").
Where("`repository`.is_private = ?", true).
Select("SUM(git_size) AS code").
Get(&used.Size.Repos.Private)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "repositories").
Where("`repository`.is_private = ?", false).
Select("SUM(git_size) AS code").
Get(&used.Size.Repos.Public)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "repositories").
Select("SUM(lfs_size) AS lfs").
Get(&used.Size.Git.LFS)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "attachments").
Select("SUM(`attachment`.size) AS size").
Where("`attachment`.release_id != 0").
Get(&used.Size.Assets.Attachments.Releases)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "attachments").
Select("SUM(`attachment`.size) AS size").
Where("`attachment`.release_id = 0").
Get(&used.Size.Assets.Attachments.Issues)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "artifacts").
Select("SUM(file_compressed_size) AS size").
Get(&used.Size.Assets.Artifacts)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "packages").
Select("SUM(package_blob.size) AS size").
Get(&used.Size.Assets.Packages.All)
if err != nil {
return nil, err
}
return &used, nil
}

17
modules/setting/quota.go Normal file
View file

@ -0,0 +1,17 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
// Quota settings
var Quota = struct {
Enabled bool `ini:"ENABLED"`
DefaultGroups []string `ini:"DEFAULT_GROUPS"`
}{
Enabled: false,
DefaultGroups: []string{},
}
func loadQuotaFrom(rootCfg ConfigProvider) {
mustMapSetting(rootCfg, "quota", &Quota)
}

View file

@ -155,6 +155,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadGitFrom(cfg)
loadMirrorFrom(cfg)
loadMarkupFrom(cfg)
loadQuotaFrom(cfg)
loadOtherFrom(cfg)
return nil
}

163
modules/structs/quota.go Normal file
View file

@ -0,0 +1,163 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
// QuotaInfo represents information about a user's quota
type QuotaInfo struct {
Used QuotaUsed `json:"used"`
Groups QuotaGroupList `json:"groups"`
}
// QuotaUsed represents the quota usage of a user
type QuotaUsed struct {
Size QuotaUsedSize `json:"size"`
}
// QuotaUsedSize represents the size-based quota usage of a user
type QuotaUsedSize struct {
Repos QuotaUsedSizeRepos `json:"repos"`
Git QuotaUsedSizeGit `json:"git"`
Assets QuotaUsedSizeAssets `json:"assets"`
}
// QuotaUsedSizeRepos represents the size-based repository quota usage of a user
type QuotaUsedSizeRepos struct {
// Storage size of the user's public repositories
Public int64 `json:"public"`
// Storage size of the user's private repositories
Private int64 `json:"private"`
}
// QuotaUsedSizeGit represents the size-based git (lfs) quota usage of a user
type QuotaUsedSizeGit struct {
// Storage size of the user's Git LFS objects
LFS int64 `json:"LFS"`
}
// QuotaUsedSizeAssets represents the size-based asset usage of a user
type QuotaUsedSizeAssets struct {
Attachments QuotaUsedSizeAssetsAttachments `json:"attachments"`
// Storage size used for the user's artifacts
Artifacts int64 `json:"artifacts"`
Packages QuotaUsedSizeAssetsPackages `json:"packages"`
}
// QuotaUsedSizeAssetsAttachments represents the size-based attachment quota usage of a user
type QuotaUsedSizeAssetsAttachments struct {
// Storage size used for the user's issue & comment attachments
Issues int64 `json:"issues"`
// Storage size used for the user's release attachments
Releases int64 `json:"releases"`
}
// QuotaUsedSizeAssetsPackages represents the size-based package quota usage of a user
type QuotaUsedSizeAssetsPackages struct {
// Storage suze used for the user's packages
All int64 `json:"all"`
}
// QuotaRuleInfo contains information about a quota rule
type QuotaRuleInfo struct {
// Name of the rule (only shown to admins)
Name string `json:"name,omitempty"`
// The limit set by the rule
Limit int64 `json:"limit"`
// Subjects the rule affects
Subjects []string `json:"subjects,omitempty"`
}
// QuotaGroupList represents a list of quota groups
type QuotaGroupList []QuotaGroup
// QuotaGroup represents a quota group
type QuotaGroup struct {
// Name of the group
Name string `json:"name,omitempty"`
// Rules associated with the group
Rules []QuotaRuleInfo `json:"rules"`
}
// CreateQutaGroupOptions represents the options for creating a quota group
type CreateQuotaGroupOptions struct {
// Name of the quota group to create
Name string `json:"name" binding:"Required"`
// Rules to add to the newly created group.
// If a rule does not exist, it will be created.
Rules []CreateQuotaRuleOptions `json:"rules"`
}
// CreateQuotaRuleOptions represents the options for creating a quota rule
type CreateQuotaRuleOptions struct {
// Name of the rule to create
Name string `json:"name" binding:"Required"`
// The limit set by the rule
Limit *int64 `json:"limit"`
// The subjects affected by the rule
Subjects []string `json:"subjects"`
}
// EditQuotaRuleOptions represents the options for editing a quota rule
type EditQuotaRuleOptions struct {
// The limit set by the rule
Limit *int64 `json:"limit"`
// The subjects affected by the rule
Subjects *[]string `json:"subjects"`
}
// SetUserQuotaGroupsOptions represents the quota groups of a user
type SetUserQuotaGroupsOptions struct {
// Quota groups the user shall have
// required: true
Groups *[]string `json:"groups"`
}
// QuotaUsedAttachmentList represents a list of attachment counting towards a user's quota
type QuotaUsedAttachmentList []*QuotaUsedAttachment
// QuotaUsedAttachment represents an attachment counting towards a user's quota
type QuotaUsedAttachment struct {
// Filename of the attachment
Name string `json:"name"`
// Size of the attachment (in bytes)
Size int64 `json:"size"`
// API URL for the attachment
APIURL string `json:"api_url"`
// Context for the attachment: URLs to the containing object
ContainedIn struct {
// API URL for the object that contains this attachment
APIURL string `json:"api_url"`
// HTML URL for the object that contains this attachment
HTMLURL string `json:"html_url"`
} `json:"contained_in"`
}
// QuotaUsedPackageList represents a list of packages counting towards a user's quota
type QuotaUsedPackageList []*QuotaUsedPackage
// QuotaUsedPackage represents a package counting towards a user's quota
type QuotaUsedPackage struct {
// Name of the package
Name string `json:"name"`
// Type of the package
Type string `json:"type"`
// Version of the package
Version string `json:"version"`
// Size of the package version
Size int64 `json:"size"`
// HTML URL to the package version
HTMLURL string `json:"html_url"`
}
// QuotaUsedArtifactList represents a list of artifacts counting towards a user's quota
type QuotaUsedArtifactList []*QuotaUsedArtifact
// QuotaUsedArtifact represents an artifact counting towards a user's quota
type QuotaUsedArtifact struct {
// Name of the artifact
Name string `json:"name"`
// Size of the artifact (compressed)
Size int64 `json:"size"`
// HTML URL to the action run containing the artifact
HTMLURL string `json:"html_url"`
}

View file

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

1
release-notes/4212.md Normal file
View file

@ -0,0 +1 @@
Added the foundations of a flexible, configurable quota system

View file

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

View file

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

View file

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

View file

@ -0,0 +1,53 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
quota_model "code.gitea.io/gitea/models/quota"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
)
// GetUserQuota return information about a user's quota
func GetUserQuota(ctx *context.APIContext) {
// swagger:operation GET /admin/users/{username}/quota admin adminGetUserQuota
// ---
// summary: Get the user's quota info
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of user to query
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/QuotaInfo"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
used, err := quota_model.GetUsedForUser(ctx, ctx.ContextUser.ID)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.GetUsedForUser", err)
return
}
groups, err := quota_model.GetGroupsForUser(ctx, ctx.ContextUser.ID)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.GetGroupsForUser", err)
return
}
result := convert.ToQuotaInfo(used, groups, true)
ctx.JSON(http.StatusOK, &result)
}

View file

@ -0,0 +1,436 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
go_context "context"
"net/http"
"code.gitea.io/gitea/models/db"
quota_model "code.gitea.io/gitea/models/quota"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
)
// ListQuotaGroups returns all the quota groups
func ListQuotaGroups(ctx *context.APIContext) {
// swagger:operation GET /admin/quota/groups admin adminListQuotaGroups
// ---
// summary: List the available quota groups
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/QuotaGroupList"
// "403":
// "$ref": "#/responses/forbidden"
groups, err := quota_model.ListGroups(ctx)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.ListGroups", err)
return
}
for _, group := range groups {
if err = group.LoadRules(ctx); err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.group.LoadRules", err)
return
}
}
ctx.JSON(http.StatusOK, convert.ToQuotaGroupList(groups, true))
}
func createQuotaGroupWithRules(ctx go_context.Context, opts *api.CreateQuotaGroupOptions) (*quota_model.Group, error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
defer committer.Close()
group, err := quota_model.CreateGroup(ctx, opts.Name)
if err != nil {
return nil, err
}
for _, rule := range opts.Rules {
exists, err := quota_model.DoesRuleExist(ctx, rule.Name)
if err != nil {
return nil, err
}
if !exists {
var limit int64
if rule.Limit != nil {
limit = *rule.Limit
}
subjects, err := toLimitSubjects(rule.Subjects)
if err != nil {
return nil, err
}
_, err = quota_model.CreateRule(ctx, rule.Name, limit, *subjects)
if err != nil {
return nil, err
}
}
if err = group.AddRuleByName(ctx, rule.Name); err != nil {
return nil, err
}
}
if err = group.LoadRules(ctx); err != nil {
return nil, err
}
return group, committer.Commit()
}
// CreateQuotaGroup creates a new quota group
func CreateQuotaGroup(ctx *context.APIContext) {
// swagger:operation POST /admin/quota/groups admin adminCreateQuotaGroup
// ---
// summary: Create a new quota group
// produces:
// - application/json
// parameters:
// - name: group
// in: body
// description: Definition of the quota group
// schema:
// "$ref": "#/definitions/CreateQuotaGroupOptions"
// required: true
// responses:
// "201":
// "$ref": "#/responses/QuotaGroup"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "409":
// "$ref": "#/responses/error"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.CreateQuotaGroupOptions)
group, err := createQuotaGroupWithRules(ctx, form)
if err != nil {
if quota_model.IsErrGroupAlreadyExists(err) {
ctx.Error(http.StatusConflict, "", err)
} else if quota_model.IsErrParseLimitSubjectUnrecognized(err) {
ctx.Error(http.StatusUnprocessableEntity, "", err)
} else {
ctx.Error(http.StatusInternalServerError, "quota_model.CreateGroup", err)
}
return
}
ctx.JSON(http.StatusCreated, convert.ToQuotaGroup(*group, true))
}
// ListUsersInQuotaGroup lists all the users in a quota group
func ListUsersInQuotaGroup(ctx *context.APIContext) {
// swagger:operation GET /admin/quota/groups/{quotagroup}/users admin adminListUsersInQuotaGroup
// ---
// summary: List users in a quota group
// produces:
// - application/json
// parameters:
// - name: quotagroup
// in: path
// description: quota group to list members of
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/UserList"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
users, err := quota_model.ListUsersInGroup(ctx, ctx.QuotaGroup.Name)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.ListUsersInGroup", err)
return
}
ctx.JSON(http.StatusOK, convert.ToUsers(ctx, ctx.Doer, users))
}
// AddUserToQuotaGroup adds a user to a quota group
func AddUserToQuotaGroup(ctx *context.APIContext) {
// swagger:operation PUT /admin/quota/groups/{quotagroup}/users/{username} admin adminAddUserToQuotaGroup
// ---
// summary: Add a user to a quota group
// produces:
// - application/json
// parameters:
// - name: quotagroup
// in: path
// description: quota group to add the user to
// type: string
// required: true
// - name: username
// in: path
// description: username of the user to add to the quota group
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "409":
// "$ref": "#/responses/error"
// "422":
// "$ref": "#/responses/validationError"
err := ctx.QuotaGroup.AddUserByID(ctx, ctx.ContextUser.ID)
if err != nil {
if quota_model.IsErrUserAlreadyInGroup(err) {
ctx.Error(http.StatusConflict, "", err)
} else {
ctx.Error(http.StatusInternalServerError, "quota_group.group.AddUserByID", err)
}
return
}
ctx.Status(http.StatusNoContent)
}
// RemoveUserFromQuotaGroup removes a user from a quota group
func RemoveUserFromQuotaGroup(ctx *context.APIContext) {
// swagger:operation DELETE /admin/quota/groups/{quotagroup}/users/{username} admin adminRemoveUserFromQuotaGroup
// ---
// summary: Remove a user from a quota group
// produces:
// - application/json
// parameters:
// - name: quotagroup
// in: path
// description: quota group to remove a user from
// type: string
// required: true
// - name: username
// in: path
// description: username of the user to add to the quota group
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
err := ctx.QuotaGroup.RemoveUserByID(ctx, ctx.ContextUser.ID)
if err != nil {
if quota_model.IsErrUserNotInGroup(err) {
ctx.NotFound()
} else {
ctx.Error(http.StatusInternalServerError, "quota_model.group.RemoveUserByID", err)
}
return
}
ctx.Status(http.StatusNoContent)
}
// SetUserQuotaGroups moves the user to specific quota groups
func SetUserQuotaGroups(ctx *context.APIContext) {
// swagger:operation POST /admin/users/{username}/quota/groups admin adminSetUserQuotaGroups
// ---
// summary: Set the user's quota groups to a given list.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of the user to add to the quota group
// type: string
// required: true
// - name: groups
// in: body
// description: quota group to remove a user from
// schema:
// "$ref": "#/definitions/SetUserQuotaGroupsOptions"
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.SetUserQuotaGroupsOptions)
err := quota_model.SetUserGroups(ctx, ctx.ContextUser.ID, form.Groups)
if err != nil {
if quota_model.IsErrGroupNotFound(err) {
ctx.Error(http.StatusUnprocessableEntity, "", err)
} else {
ctx.Error(http.StatusInternalServerError, "quota_model.SetUserGroups", err)
}
return
}
ctx.Status(http.StatusNoContent)
}
// DeleteQuotaGroup deletes a quota group
func DeleteQuotaGroup(ctx *context.APIContext) {
// swagger:operation DELETE /admin/quota/groups/{quotagroup} admin adminDeleteQuotaGroup
// ---
// summary: Delete a quota group
// produces:
// - application/json
// parameters:
// - name: quotagroup
// in: path
// description: quota group to delete
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
err := quota_model.DeleteGroupByName(ctx, ctx.QuotaGroup.Name)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.DeleteGroupByName", err)
return
}
ctx.Status(http.StatusNoContent)
}
// GetQuotaGroup returns information about a quota group
func GetQuotaGroup(ctx *context.APIContext) {
// swagger:operation GET /admin/quota/groups/{quotagroup} admin adminGetQuotaGroup
// ---
// summary: Get information about the quota group
// produces:
// - application/json
// parameters:
// - name: quotagroup
// in: path
// description: quota group to query
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/QuotaGroup"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
ctx.JSON(http.StatusOK, convert.ToQuotaGroup(*ctx.QuotaGroup, true))
}
// AddRuleToQuotaGroup adds a rule to a quota group
func AddRuleToQuotaGroup(ctx *context.APIContext) {
// swagger:operation PUT /admin/quota/groups/{quotagroup}/rules/{quotarule} admin adminAddRuleToQuotaGroup
// ---
// summary: Adds a rule to a quota group
// produces:
// - application/json
// parameters:
// - name: quotagroup
// in: path
// description: quota group to add a rule to
// type: string
// required: true
// - name: quotarule
// in: path
// description: the name of the quota rule to add to the group
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "409":
// "$ref": "#/responses/error"
// "422":
// "$ref": "#/responses/validationError"
err := ctx.QuotaGroup.AddRuleByName(ctx, ctx.QuotaRule.Name)
if err != nil {
if quota_model.IsErrRuleAlreadyInGroup(err) {
ctx.Error(http.StatusConflict, "", err)
} else if quota_model.IsErrRuleNotFound(err) {
ctx.Error(http.StatusUnprocessableEntity, "", err)
} else {
ctx.Error(http.StatusInternalServerError, "quota_model.group.AddRuleByName", err)
}
return
}
ctx.Status(http.StatusNoContent)
}
// RemoveRuleFromQuotaGroup removes a rule from a quota group
func RemoveRuleFromQuotaGroup(ctx *context.APIContext) {
// swagger:operation DELETE /admin/quota/groups/{quotagroup}/rules/{quotarule} admin adminRemoveRuleFromQuotaGroup
// ---
// summary: Removes a rule from a quota group
// produces:
// - application/json
// parameters:
// - name: quotagroup
// in: path
// description: quota group to add a rule to
// type: string
// required: true
// - name: quotarule
// in: path
// description: the name of the quota rule to remove from the group
// type: string
// required: true
// responses:
// "201":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
err := ctx.QuotaGroup.RemoveRuleByName(ctx, ctx.QuotaRule.Name)
if err != nil {
if quota_model.IsErrRuleNotInGroup(err) {
ctx.NotFound()
} else {
ctx.Error(http.StatusInternalServerError, "quota_model.group.RemoveRuleByName", err)
}
return
}
ctx.Status(http.StatusNoContent)
}

View file

@ -0,0 +1,219 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"fmt"
"net/http"
quota_model "code.gitea.io/gitea/models/quota"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
)
func toLimitSubjects(subjStrings []string) (*quota_model.LimitSubjects, error) {
subjects := make(quota_model.LimitSubjects, len(subjStrings))
for i := range len(subjStrings) {
subj, err := quota_model.ParseLimitSubject(subjStrings[i])
if err != nil {
return nil, err
}
subjects[i] = subj
}
return &subjects, nil
}
// ListQuotaRules lists all the quota rules
func ListQuotaRules(ctx *context.APIContext) {
// swagger:operation GET /admin/quota/rules admin adminListQuotaRules
// ---
// summary: List the available quota rules
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/QuotaRuleInfoList"
// "403":
// "$ref": "#/responses/forbidden"
rules, err := quota_model.ListRules(ctx)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.ListQuotaRules", err)
return
}
result := make([]api.QuotaRuleInfo, len(rules))
for i := range len(rules) {
result[i] = convert.ToQuotaRuleInfo(rules[i], true)
}
ctx.JSON(http.StatusOK, result)
}
// CreateQuotaRule creates a new quota rule
func CreateQuotaRule(ctx *context.APIContext) {
// swagger:operation POST /admin/quota/rules admin adminCreateQuotaRule
// ---
// summary: Create a new quota rule
// produces:
// - application/json
// parameters:
// - name: rule
// in: body
// description: Definition of the quota rule
// schema:
// "$ref": "#/definitions/CreateQuotaRuleOptions"
// required: true
// responses:
// "201":
// "$ref": "#/responses/QuotaRuleInfo"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "409":
// "$ref": "#/responses/error"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.CreateQuotaRuleOptions)
if form.Limit == nil {
ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", fmt.Errorf("[Limit]: Required"))
return
}
subjects, err := toLimitSubjects(form.Subjects)
if err != nil {
ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", err)
return
}
rule, err := quota_model.CreateRule(ctx, form.Name, *form.Limit, *subjects)
if err != nil {
if quota_model.IsErrRuleAlreadyExists(err) {
ctx.Error(http.StatusConflict, "", err)
} else {
ctx.Error(http.StatusInternalServerError, "quota_model.CreateRule", err)
}
return
}
ctx.JSON(http.StatusCreated, convert.ToQuotaRuleInfo(*rule, true))
}
// GetQuotaRule returns information about the specified quota rule
func GetQuotaRule(ctx *context.APIContext) {
// swagger:operation GET /admin/quota/rules/{quotarule} admin adminGetQuotaRule
// ---
// summary: Get information about a quota rule
// produces:
// - application/json
// parameters:
// - name: quotarule
// in: path
// description: quota rule to query
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/QuotaRuleInfo"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
ctx.JSON(http.StatusOK, convert.ToQuotaRuleInfo(*ctx.QuotaRule, true))
}
// EditQuotaRule changes an existing quota rule
func EditQuotaRule(ctx *context.APIContext) {
// swagger:operation PATCH /admin/quota/rules/{quotarule} admin adminEditQuotaRule
// ---
// summary: Change an existing quota rule
// produces:
// - application/json
// parameters:
// - name: quotarule
// in: path
// description: Quota rule to change
// type: string
// required: true
// - name: rule
// in: body
// schema:
// "$ref": "#/definitions/EditQuotaRuleOptions"
// required: true
// responses:
// "200":
// "$ref": "#/responses/QuotaRuleInfo"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.EditQuotaRuleOptions)
var subjects *quota_model.LimitSubjects
if form.Subjects != nil {
subjs := make(quota_model.LimitSubjects, len(*form.Subjects))
for i := range len(*form.Subjects) {
subj, err := quota_model.ParseLimitSubject((*form.Subjects)[i])
if err != nil {
ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", err)
return
}
subjs[i] = subj
}
subjects = &subjs
}
rule, err := ctx.QuotaRule.Edit(ctx, form.Limit, subjects)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.rule.Edit", err)
return
}
ctx.JSON(http.StatusOK, convert.ToQuotaRuleInfo(*rule, true))
}
// DeleteQuotaRule deletes a quota rule
func DeleteQuotaRule(ctx *context.APIContext) {
// swagger:operation DELETE /admin/quota/rules/{quotarule} admin adminDEleteQuotaRule
// ---
// summary: Deletes a quota rule
// produces:
// - application/json
// parameters:
// - name: quotarule
// in: path
// description: quota rule to delete
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
err := quota_model.DeleteRuleByName(ctx, ctx.QuotaRule.Name)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.DeleteRuleByName", err)
return
}
ctx.Status(http.StatusNoContent)
}

View file

@ -1,6 +1,6 @@
// Copyright 2015 The Gogs Authors. All rights reserved.
// Copyright 2016 The Gitea Authors. All rights reserved.
// Copyright 2023 The Forgejo Authors. All rights reserved.
// Copyright 2023-2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package v1 Gitea API
@ -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"
@ -892,6 +893,15 @@ func Routes() *web.Route {
// Users (requires user scope)
m.Group("/user", func() {
m.Get("", user.GetAuthenticatedUser)
if setting.Quota.Enabled {
m.Group("/quota", func() {
m.Get("", user.GetQuota)
m.Get("/check", user.CheckQuota)
m.Get("/attachments", user.ListQuotaAttachments)
m.Get("/packages", user.ListQuotaPackages)
m.Get("/artifacts", user.ListQuotaArtifacts)
})
}
m.Group("/settings", func() {
m.Get("", user.GetUserSettings)
m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings)
@ -964,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 {
@ -1095,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)
@ -1109,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() {
@ -1143,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)
@ -1163,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)
@ -1183,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).
@ -1202,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("").
@ -1261,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)
@ -1326,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).
@ -1378,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).
@ -1440,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).
@ -1482,6 +1492,16 @@ func Routes() *web.Route {
}, reqToken(), reqOrgOwnership())
m.Get("/activities/feeds", org.ListOrgActivityFeeds)
if setting.Quota.Enabled {
m.Group("/quota", func() {
m.Get("", org.GetQuota)
m.Get("/check", org.CheckQuota)
m.Get("/attachments", org.ListQuotaAttachments)
m.Get("/packages", org.ListQuotaPackages)
m.Get("/artifacts", org.ListQuotaArtifacts)
}, reqToken(), reqOrgOwnership())
}
m.Group("", func() {
m.Get("/list_blocked", org.ListBlockedUsers)
m.Group("", func() {
@ -1531,6 +1551,12 @@ func Routes() *web.Route {
m.Post("/orgs", bind(api.CreateOrgOption{}), admin.CreateOrg)
m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo)
m.Post("/rename", bind(api.RenameUserOption{}), admin.RenameUser)
if setting.Quota.Enabled {
m.Group("/quota", func() {
m.Get("", admin.GetUserQuota)
m.Post("/groups", bind(api.SetUserQuotaGroupsOptions{}), admin.SetUserQuotaGroups)
})
}
}, context.UserAssignmentAPI())
})
m.Group("/emails", func() {
@ -1552,6 +1578,37 @@ func Routes() *web.Route {
m.Group("/runners", func() {
m.Get("/registration-token", admin.GetRegistrationToken)
})
if setting.Quota.Enabled {
m.Group("/quota", func() {
m.Group("/rules", func() {
m.Combo("").Get(admin.ListQuotaRules).
Post(bind(api.CreateQuotaRuleOptions{}), admin.CreateQuotaRule)
m.Combo("/{quotarule}", context.QuotaRuleAssignmentAPI()).
Get(admin.GetQuotaRule).
Patch(bind(api.EditQuotaRuleOptions{}), admin.EditQuotaRule).
Delete(admin.DeleteQuotaRule)
})
m.Group("/groups", func() {
m.Combo("").Get(admin.ListQuotaGroups).
Post(bind(api.CreateQuotaGroupOptions{}), admin.CreateQuotaGroup)
m.Group("/{quotagroup}", func() {
m.Combo("").Get(admin.GetQuotaGroup).
Delete(admin.DeleteQuotaGroup)
m.Group("/rules", func() {
m.Combo("/{quotarule}", context.QuotaRuleAssignmentAPI()).
Put(admin.AddRuleToQuotaGroup).
Delete(admin.RemoveRuleFromQuotaGroup)
})
m.Group("/users", func() {
m.Get("", admin.ListUsersInQuotaGroup)
m.Combo("/{username}", context.UserAssignmentAPI()).
Put(admin.AddUserToQuotaGroup).
Delete(admin.RemoveUserFromQuotaGroup)
})
}, context.QuotaGroupAssignmentAPI())
})
})
}
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin())
m.Group("/topics", func() {

155
routers/api/v1/org/quota.go Normal file
View file

@ -0,0 +1,155 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"code.gitea.io/gitea/routers/api/v1/shared"
"code.gitea.io/gitea/services/context"
)
// GetQuota returns the quota information for a given organization
func GetQuota(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/quota organization orgGetQuota
// ---
// summary: Get quota information for an organization
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/QuotaInfo"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
shared.GetQuota(ctx, ctx.Org.Organization.ID)
}
// CheckQuota returns whether the organization in context is over the subject quota
func CheckQuota(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/quota/check organization orgCheckQuota
// ---
// summary: Check if the organization is over quota for a given subject
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/boolean"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
shared.CheckQuota(ctx, ctx.Org.Organization.ID)
}
// ListQuotaAttachments lists attachments affecting the organization's quota
func ListQuotaAttachments(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/quota/attachments organization orgListQuotaAttachments
// ---
// summary: List the attachments affecting the organization's quota
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/QuotaUsedAttachmentList"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
shared.ListQuotaAttachments(ctx, ctx.Org.Organization.ID)
}
// ListQuotaPackages lists packages affecting the organization's quota
func ListQuotaPackages(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/quota/packages organization orgListQuotaPackages
// ---
// summary: List the packages affecting the organization's quota
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/QuotaUsedPackageList"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
shared.ListQuotaPackages(ctx, ctx.Org.Organization.ID)
}
// ListQuotaArtifacts lists artifacts affecting the organization's quota
func ListQuotaArtifacts(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/quota/artifacts organization orgListQuotaArtifacts
// ---
// summary: List the artifacts affecting the organization's quota
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/QuotaUsedArtifactList"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
shared.ListQuotaArtifacts(ctx, ctx.Org.Organization.ID)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,102 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package shared
import (
"net/http"
quota_model "code.gitea.io/gitea/models/quota"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
)
func GetQuota(ctx *context.APIContext, userID int64) {
used, err := quota_model.GetUsedForUser(ctx, userID)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.GetUsedForUser", err)
return
}
groups, err := quota_model.GetGroupsForUser(ctx, userID)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.GetGroupsForUser", err)
return
}
result := convert.ToQuotaInfo(used, groups, false)
ctx.JSON(http.StatusOK, &result)
}
func CheckQuota(ctx *context.APIContext, userID int64) {
subjectQuery := ctx.FormTrim("subject")
subject, err := quota_model.ParseLimitSubject(subjectQuery)
if err != nil {
ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", err)
return
}
ok, err := quota_model.EvaluateForUser(ctx, userID, subject)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.EvaluateForUser", err)
return
}
ctx.JSON(http.StatusOK, &ok)
}
func ListQuotaAttachments(ctx *context.APIContext, userID int64) {
opts := utils.GetListOptions(ctx)
count, attachments, err := quota_model.GetQuotaAttachmentsForUser(ctx, userID, opts)
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetQuotaAttachmentsForUser", err)
return
}
result, err := convert.ToQuotaUsedAttachmentList(ctx, *attachments)
if err != nil {
ctx.Error(http.StatusInternalServerError, "convert.ToQuotaUsedAttachmentList", err)
}
ctx.SetLinkHeader(int(count), opts.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, result)
}
func ListQuotaPackages(ctx *context.APIContext, userID int64) {
opts := utils.GetListOptions(ctx)
count, packages, err := quota_model.GetQuotaPackagesForUser(ctx, userID, opts)
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetQuotaPackagesForUser", err)
return
}
result, err := convert.ToQuotaUsedPackageList(ctx, *packages)
if err != nil {
ctx.Error(http.StatusInternalServerError, "convert.ToQuotaUsedPackageList", err)
}
ctx.SetLinkHeader(int(count), opts.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, result)
}
func ListQuotaArtifacts(ctx *context.APIContext, userID int64) {
opts := utils.GetListOptions(ctx)
count, artifacts, err := quota_model.GetQuotaArtifactsForUser(ctx, userID, opts)
if err != nil {
ctx.Error(http.StatusInternalServerError, "GetQuotaArtifactsForUser", err)
return
}
result, err := convert.ToQuotaUsedArtifactList(ctx, *artifacts)
if err != nil {
ctx.Error(http.StatusInternalServerError, "convert.ToQuotaUsedArtifactList", err)
}
ctx.SetLinkHeader(int(count), opts.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, result)
}

View file

@ -62,3 +62,10 @@ type swaggerResponseLabelTemplateInfo struct {
// in:body
Body []api.LabelTemplate `json:"body"`
}
// Boolean
// swagger:response boolean
type swaggerResponseBoolean struct {
// in:body
Body bool `json:"body"`
}

View file

@ -219,4 +219,16 @@ type swaggerParameterBodies struct {
// in:body
DispatchWorkflowOption api.DispatchWorkflowOption
// in:body
CreateQuotaGroupOptions api.CreateQuotaGroupOptions
// in:body
CreateQuotaRuleOptions api.CreateQuotaRuleOptions
// in:body
EditQuotaRuleOptions api.EditQuotaRuleOptions
// in:body
SetUserQuotaGroupsOptions api.SetUserQuotaGroupsOptions
}

View file

@ -0,0 +1,64 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package swagger
import (
api "code.gitea.io/gitea/modules/structs"
)
// QuotaInfo
// swagger:response QuotaInfo
type swaggerResponseQuotaInfo struct {
// in:body
Body api.QuotaInfo `json:"body"`
}
// QuotaRuleInfoList
// swagger:response QuotaRuleInfoList
type swaggerResponseQuotaRuleInfoList struct {
// in:body
Body []api.QuotaRuleInfo `json:"body"`
}
// QuotaRuleInfo
// swagger:response QuotaRuleInfo
type swaggerResponseQuotaRuleInfo struct {
// in:body
Body api.QuotaRuleInfo `json:"body"`
}
// QuotaUsedAttachmentList
// swagger:response QuotaUsedAttachmentList
type swaggerQuotaUsedAttachmentList struct {
// in:body
Body api.QuotaUsedAttachmentList `json:"body"`
}
// QuotaUsedPackageList
// swagger:response QuotaUsedPackageList
type swaggerQuotaUsedPackageList struct {
// in:body
Body api.QuotaUsedPackageList `json:"body"`
}
// QuotaUsedArtifactList
// swagger:response QuotaUsedArtifactList
type swaggerQuotaUsedArtifactList struct {
// in:body
Body api.QuotaUsedArtifactList `json:"body"`
}
// QuotaGroup
// swagger:response QuotaGroup
type swaggerResponseQuotaGroup struct {
// in:body
Body api.QuotaGroup `json:"body"`
}
// QuotaGroupList
// swagger:response QuotaGroupList
type swaggerResponseQuotaGroupList struct {
// in:body
Body api.QuotaGroupList `json:"body"`
}

View file

@ -0,0 +1,118 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"code.gitea.io/gitea/routers/api/v1/shared"
"code.gitea.io/gitea/services/context"
)
// GetQuota returns the quota information for the authenticated user
func GetQuota(ctx *context.APIContext) {
// swagger:operation GET /user/quota user userGetQuota
// ---
// summary: Get quota information for the authenticated user
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/QuotaInfo"
// "403":
// "$ref": "#/responses/forbidden"
shared.GetQuota(ctx, ctx.Doer.ID)
}
// CheckQuota returns whether the authenticated user is over the subject quota
func CheckQuota(ctx *context.APIContext) {
// swagger:operation GET /user/quota/check user userCheckQuota
// ---
// summary: Check if the authenticated user is over quota for a given subject
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/boolean"
// "403":
// "$ref": "#/responses/forbidden"
// "422":
// "$ref": "#/responses/validationError"
shared.CheckQuota(ctx, ctx.Doer.ID)
}
// ListQuotaAttachments lists attachments affecting the authenticated user's quota
func ListQuotaAttachments(ctx *context.APIContext) {
// swagger:operation GET /user/quota/attachments user userListQuotaAttachments
// ---
// summary: List the attachments affecting the authenticated user's quota
// produces:
// - application/json
// parameters:
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/QuotaUsedAttachmentList"
// "403":
// "$ref": "#/responses/forbidden"
shared.ListQuotaAttachments(ctx, ctx.Doer.ID)
}
// ListQuotaPackages lists packages affecting the authenticated user's quota
func ListQuotaPackages(ctx *context.APIContext) {
// swagger:operation GET /user/quota/packages user userListQuotaPackages
// ---
// summary: List the packages affecting the authenticated user's quota
// produces:
// - application/json
// parameters:
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/QuotaUsedPackageList"
// "403":
// "$ref": "#/responses/forbidden"
shared.ListQuotaPackages(ctx, ctx.Doer.ID)
}
// ListQuotaArtifacts lists artifacts affecting the authenticated user's quota
func ListQuotaArtifacts(ctx *context.APIContext) {
// swagger:operation GET /user/quota/artifacts user userListQuotaArtifacts
// ---
// summary: List the artifacts affecting the authenticated user's quota
// produces:
// - application/json
// parameters:
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/QuotaUsedArtifactList"
// "403":
// "$ref": "#/responses/forbidden"
shared.ListQuotaArtifacts(ctx, ctx.Doer.ID)
}

View file

@ -99,9 +99,15 @@ func ListMyRepos(ctx *context.APIContext) {
// in: query
// description: page size of results
// type: integer
// - name: order_by
// in: query
// description: order the repositories by name (default), id, or size
// type: string
// responses:
// "200":
// "$ref": "#/responses/RepositoryList"
// "422":
// "$ref": "#/responses/validationError"
opts := &repo_model.SearchRepoOptions{
ListOptions: utils.GetListOptions(ctx),
@ -110,6 +116,19 @@ func ListMyRepos(ctx *context.APIContext) {
Private: ctx.IsSigned,
IncludeDescription: true,
}
orderBy := ctx.FormTrim("order_by")
switch orderBy {
case "name":
opts.OrderBy = "name ASC"
case "size":
opts.OrderBy = "size DESC"
case "id":
opts.OrderBy = "id ASC"
case "":
default:
ctx.Error(http.StatusUnprocessableEntity, "", "invalid order_by")
return
}
var err error
repos, count, err := repo_model.SearchRepository(ctx, opts)

View file

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

View file

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

View file

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

View file

@ -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:

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import (
"strings"
issues_model "code.gitea.io/gitea/models/issues"
quota_model "code.gitea.io/gitea/models/quota"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
mc "code.gitea.io/gitea/modules/cache"
@ -38,10 +39,12 @@ type APIContext struct {
ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer
Repo *Repository
Comment *issues_model.Comment
Org *APIOrganization
Package *Package
Repo *Repository
Comment *issues_model.Comment
Org *APIOrganization
Package *Package
QuotaGroup *quota_model.Group
QuotaRule *quota_model.Rule
}
func init() {

200
services/context/quota.go Normal file
View file

@ -0,0 +1,200 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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) {
groupName := ctx.Params("quotagroup")
group, err := quota_model.GetGroupByName(ctx, groupName)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.GetGroupByName", err)
return
}
if group == nil {
ctx.NotFound()
return
}
ctx.QuotaGroup = group
}
}
// QuotaRuleAssignmentAPI returns a middleware to handle context-quota-rule assignment for api routes
func QuotaRuleAssignmentAPI() func(ctx *APIContext) {
return func(ctx *APIContext) {
ruleName := ctx.Params("quotarule")
rule, err := quota_model.GetRuleByName(ctx, ruleName)
if err != nil {
ctx.Error(http.StatusInternalServerError, "quota_model.GetRuleByName", err)
return
}
if rule == nil {
ctx.NotFound()
return
}
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)
}

185
services/convert/quota.go Normal file
View file

@ -0,0 +1,185 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package convert
import (
"context"
"strconv"
action_model "code.gitea.io/gitea/models/actions"
issue_model "code.gitea.io/gitea/models/issues"
package_model "code.gitea.io/gitea/models/packages"
quota_model "code.gitea.io/gitea/models/quota"
repo_model "code.gitea.io/gitea/models/repo"
api "code.gitea.io/gitea/modules/structs"
)
func ToQuotaRuleInfo(rule quota_model.Rule, withName bool) api.QuotaRuleInfo {
info := api.QuotaRuleInfo{
Limit: rule.Limit,
Subjects: make([]string, len(rule.Subjects)),
}
for i := range len(rule.Subjects) {
info.Subjects[i] = rule.Subjects[i].String()
}
if withName {
info.Name = rule.Name
}
return info
}
func toQuotaInfoUsed(used *quota_model.Used) api.QuotaUsed {
info := api.QuotaUsed{
Size: api.QuotaUsedSize{
Repos: api.QuotaUsedSizeRepos{
Public: used.Size.Repos.Public,
Private: used.Size.Repos.Private,
},
Git: api.QuotaUsedSizeGit{
LFS: used.Size.Git.LFS,
},
Assets: api.QuotaUsedSizeAssets{
Attachments: api.QuotaUsedSizeAssetsAttachments{
Issues: used.Size.Assets.Attachments.Issues,
Releases: used.Size.Assets.Attachments.Releases,
},
Artifacts: used.Size.Assets.Artifacts,
Packages: api.QuotaUsedSizeAssetsPackages{
All: used.Size.Assets.Packages.All,
},
},
},
}
return info
}
func ToQuotaInfo(used *quota_model.Used, groups quota_model.GroupList, withNames bool) api.QuotaInfo {
info := api.QuotaInfo{
Used: toQuotaInfoUsed(used),
Groups: ToQuotaGroupList(groups, withNames),
}
return info
}
func ToQuotaGroup(group quota_model.Group, withNames bool) api.QuotaGroup {
info := api.QuotaGroup{
Rules: make([]api.QuotaRuleInfo, len(group.Rules)),
}
if withNames {
info.Name = group.Name
}
for i := range len(group.Rules) {
info.Rules[i] = ToQuotaRuleInfo(group.Rules[i], withNames)
}
return info
}
func ToQuotaGroupList(groups quota_model.GroupList, withNames bool) api.QuotaGroupList {
list := make(api.QuotaGroupList, len(groups))
for i := range len(groups) {
list[i] = ToQuotaGroup(*groups[i], withNames)
}
return list
}
func ToQuotaUsedAttachmentList(ctx context.Context, attachments []*repo_model.Attachment) (*api.QuotaUsedAttachmentList, error) {
getAttachmentContainer := func(a *repo_model.Attachment) (string, string, error) {
if a.ReleaseID != 0 {
release, err := repo_model.GetReleaseByID(ctx, a.ReleaseID)
if err != nil {
return "", "", err
}
if err = release.LoadAttributes(ctx); err != nil {
return "", "", err
}
return release.APIURL(), release.HTMLURL(), nil
}
if a.CommentID != 0 {
comment, err := issue_model.GetCommentByID(ctx, a.CommentID)
if err != nil {
return "", "", err
}
return comment.APIURL(ctx), comment.HTMLURL(ctx), nil
}
if a.IssueID != 0 {
issue, err := issue_model.GetIssueByID(ctx, a.IssueID)
if err != nil {
return "", "", err
}
if err = issue.LoadRepo(ctx); err != nil {
return "", "", err
}
return issue.APIURL(ctx), issue.HTMLURL(), nil
}
return "", "", nil
}
result := make(api.QuotaUsedAttachmentList, len(attachments))
for i, a := range attachments {
capiURL, chtmlURL, err := getAttachmentContainer(a)
if err != nil {
return nil, err
}
apiURL := capiURL + "/assets/" + strconv.FormatInt(a.ID, 10)
result[i] = &api.QuotaUsedAttachment{
Name: a.Name,
Size: a.Size,
APIURL: apiURL,
}
result[i].ContainedIn.APIURL = capiURL
result[i].ContainedIn.HTMLURL = chtmlURL
}
return &result, nil
}
func ToQuotaUsedPackageList(ctx context.Context, packages []*package_model.PackageVersion) (*api.QuotaUsedPackageList, error) {
result := make(api.QuotaUsedPackageList, len(packages))
for i, pv := range packages {
d, err := package_model.GetPackageDescriptor(ctx, pv)
if err != nil {
return nil, err
}
var size int64
for _, file := range d.Files {
size += file.Blob.Size
}
result[i] = &api.QuotaUsedPackage{
Name: d.Package.Name,
Type: d.Package.Type.Name(),
Version: d.Version.Version,
Size: size,
HTMLURL: d.VersionHTMLURL(),
}
}
return &result, nil
}
func ToQuotaUsedArtifactList(ctx context.Context, artifacts []*action_model.ActionArtifact) (*api.QuotaUsedArtifactList, error) {
result := make(api.QuotaUsedArtifactList, len(artifacts))
for i, a := range artifacts {
run, err := action_model.GetRunByID(ctx, a.RunID)
if err != nil {
return nil, err
}
result[i] = &api.QuotaUsedArtifact{
Name: a.ArtifactName,
Size: a.FileCompressedSize,
HTMLURL: run.HTMLURL(),
}
}
return &result, nil
}

View file

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

View file

@ -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 {

View file

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

11
templates/status/413.tmpl Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,846 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
quota_model "code.gitea.io/gitea/models/quota"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"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/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIQuotaDisabled(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Quota.Enabled, false)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
session := loginUser(t, user.Name)
req := NewRequest(t, "GET", "/api/v1/user/quota")
session.MakeRequest(t, req, http.StatusNotFound)
}
func apiCreateUser(t *testing.T, username string) func() {
t.Helper()
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
session := loginUser(t, admin.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
mustChangePassword := false
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users", api.CreateUserOption{
Email: "api+" + username + "@example.com",
Username: username,
Password: "password",
MustChangePassword: &mustChangePassword,
}).AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusCreated)
return func() {
req := NewRequest(t, "DELETE", "/api/v1/admin/users/"+username+"?purge=true").AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusNoContent)
}
}
func TestAPIQuotaCreateGroupWithRules(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Quota.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
// Create two rules in advance
unlimited := int64(-1)
defer createQuotaRule(t, api.CreateQuotaRuleOptions{
Name: "unlimited",
Limit: &unlimited,
Subjects: []string{"size:all"},
})()
zero := int64(0)
defer createQuotaRule(t, api.CreateQuotaRuleOptions{
Name: "deny-git-lfs",
Limit: &zero,
Subjects: []string{"size:git:lfs"},
})()
// Log in as admin
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
adminSession := loginUser(t, admin.Name)
adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
// Create a new group, with rules specified
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
Name: "group-with-rules",
Rules: []api.CreateQuotaRuleOptions{
// First: an existing group, unlimited, name only
{
Name: "unlimited",
},
// Second: an existing group, deny-git-lfs, with different params
{
Name: "deny-git-lfs",
Limit: &unlimited,
},
// Third: an entirely new group
{
Name: "new-rule",
Subjects: []string{"size:assets:all"},
},
},
}).AddTokenAuth(adminToken)
resp := adminSession.MakeRequest(t, req, http.StatusCreated)
defer func() {
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/group-with-rules").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/new-rule").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
}()
// Verify that we created a group with rules included
var q api.QuotaGroup
DecodeJSON(t, resp, &q)
assert.Equal(t, "group-with-rules", q.Name)
assert.Len(t, q.Rules, 3)
// Verify that the previously existing rules are unchanged
rule, err := quota_model.GetRuleByName(db.DefaultContext, "unlimited")
require.NoError(t, err)
assert.NotNil(t, rule)
assert.EqualValues(t, -1, rule.Limit)
assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll}, rule.Subjects)
rule, err = quota_model.GetRuleByName(db.DefaultContext, "deny-git-lfs")
require.NoError(t, err)
assert.NotNil(t, rule)
assert.EqualValues(t, 0, rule.Limit)
assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeGitLFS}, rule.Subjects)
// Verify that the new rule was also created
rule, err = quota_model.GetRuleByName(db.DefaultContext, "new-rule")
require.NoError(t, err)
assert.NotNil(t, rule)
assert.EqualValues(t, 0, rule.Limit)
assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAssetsAll}, rule.Subjects)
t.Run("invalid rule spec", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
Name: "group-with-invalid-rule-spec",
Rules: []api.CreateQuotaRuleOptions{
{
Name: "rule-with-wrong-spec",
Subjects: []string{"valid:false"},
},
},
}).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
}
func TestAPIQuotaEmptyState(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Quota.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
username := "quota-empty-user"
defer apiCreateUser(t, username)()
session := loginUser(t, username)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
t.Run("#/admin/users/quota-empty-user/quota", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
adminSession := loginUser(t, admin.Name)
adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
req := NewRequest(t, "GET", "/api/v1/admin/users/quota-empty-user/quota").AddTokenAuth(adminToken)
resp := adminSession.MakeRequest(t, req, http.StatusOK)
var q api.QuotaInfo
DecodeJSON(t, resp, &q)
assert.EqualValues(t, api.QuotaUsed{}, q.Used)
assert.Empty(t, q.Groups)
})
t.Run("#/user/quota", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
var q api.QuotaInfo
DecodeJSON(t, resp, &q)
assert.EqualValues(t, api.QuotaUsed{}, q.Used)
assert.Empty(t, q.Groups)
t.Run("#/user/quota/artifacts", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/user/quota/artifacts").AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
var q api.QuotaUsedArtifactList
DecodeJSON(t, resp, &q)
assert.Empty(t, q)
})
t.Run("#/user/quota/attachments", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/user/quota/attachments").AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
var q api.QuotaUsedAttachmentList
DecodeJSON(t, resp, &q)
assert.Empty(t, q)
})
t.Run("#/user/quota/packages", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/user/quota/packages").AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
var q api.QuotaUsedPackageList
DecodeJSON(t, resp, &q)
assert.Empty(t, q)
})
})
}
func createQuotaRule(t *testing.T, opts api.CreateQuotaRuleOptions) func() {
t.Helper()
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
adminSession := loginUser(t, admin.Name)
adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", opts).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusCreated)
return func() {
req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/rules/%s", opts.Name).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
}
}
func createQuotaGroup(t *testing.T, name string) func() {
t.Helper()
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
adminSession := loginUser(t, admin.Name)
adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
Name: name,
}).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusCreated)
return func() {
req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s", name).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
}
}
func TestAPIQuotaAdminRoutesRules(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Quota.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
adminSession := loginUser(t, admin.Name)
adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
zero := int64(0)
oneKb := int64(1024)
t.Run("adminCreateQuotaRule", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", api.CreateQuotaRuleOptions{
Name: "deny-all",
Limit: &zero,
Subjects: []string{"size:all"},
}).AddTokenAuth(adminToken)
resp := adminSession.MakeRequest(t, req, http.StatusCreated)
defer func() {
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
}()
var q api.QuotaRuleInfo
DecodeJSON(t, resp, &q)
assert.Equal(t, "deny-all", q.Name)
assert.EqualValues(t, 0, q.Limit)
assert.EqualValues(t, []string{"size:all"}, q.Subjects)
rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all")
require.NoError(t, err)
assert.EqualValues(t, 0, rule.Limit)
t.Run("unhappy path", func(t *testing.T) {
t.Run("missing options", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", nil).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("invalid subjects", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", api.CreateQuotaRuleOptions{
Name: "invalid-subjects",
Limit: &zero,
Subjects: []string{"valid:false"},
}).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("trying to add an existing rule", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
rule := api.CreateQuotaRuleOptions{
Name: "double-rule",
Limit: &zero,
}
defer createQuotaRule(t, rule)()
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", rule).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusConflict)
})
})
})
t.Run("adminDeleteQuotaRule", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
createQuotaRule(t, api.CreateQuotaRuleOptions{
Name: "deny-all",
Limit: &zero,
Subjects: []string{"size:all"},
})
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all")
require.NoError(t, err)
assert.Nil(t, rule)
t.Run("unhappy path", func(t *testing.T) {
t.Run("nonexistent rule", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/does-not-exist").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
})
})
t.Run("adminEditQuotaRule", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaRule(t, api.CreateQuotaRuleOptions{
Name: "deny-all",
Limit: &zero,
Subjects: []string{"size:all"},
})()
req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", api.EditQuotaRuleOptions{
Limit: &oneKb,
}).AddTokenAuth(adminToken)
resp := adminSession.MakeRequest(t, req, http.StatusOK)
var q api.QuotaRuleInfo
DecodeJSON(t, resp, &q)
assert.EqualValues(t, 1024, q.Limit)
rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all")
require.NoError(t, err)
assert.EqualValues(t, 1024, rule.Limit)
t.Run("no options", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", nil).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusOK)
})
t.Run("unhappy path", func(t *testing.T) {
t.Run("nonexistent rule", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/does-not-exist", api.EditQuotaRuleOptions{
Limit: &oneKb,
}).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("invalid subjects", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", api.EditQuotaRuleOptions{
Subjects: &[]string{"valid:false"},
}).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
})
})
t.Run("adminListQuotaRules", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaRule(t, api.CreateQuotaRuleOptions{
Name: "deny-all",
Limit: &zero,
Subjects: []string{"size:all"},
})()
req := NewRequest(t, "GET", "/api/v1/admin/quota/rules").AddTokenAuth(adminToken)
resp := adminSession.MakeRequest(t, req, http.StatusOK)
var rules []api.QuotaRuleInfo
DecodeJSON(t, resp, &rules)
assert.Len(t, rules, 1)
assert.Equal(t, "deny-all", rules[0].Name)
assert.EqualValues(t, 0, rules[0].Limit)
})
}
func TestAPIQuotaAdminRoutesGroups(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Quota.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
adminSession := loginUser(t, admin.Name)
adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
zero := int64(0)
ruleDenyAll := api.CreateQuotaRuleOptions{
Name: "deny-all",
Limit: &zero,
Subjects: []string{"size:all"},
}
username := "quota-test-user"
defer apiCreateUser(t, username)()
t.Run("adminCreateQuotaGroup", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
Name: "default",
}).AddTokenAuth(adminToken)
resp := adminSession.MakeRequest(t, req, http.StatusCreated)
defer func() {
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
}()
var q api.QuotaGroup
DecodeJSON(t, resp, &q)
assert.Equal(t, "default", q.Name)
assert.Empty(t, q.Rules)
group, err := quota_model.GetGroupByName(db.DefaultContext, "default")
require.NoError(t, err)
assert.Equal(t, "default", group.Name)
assert.Empty(t, group.Rules)
t.Run("unhappy path", func(t *testing.T) {
t.Run("missing options", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", nil).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("trying to add an existing group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaGroup(t, "duplicate")()
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
Name: "duplicate",
}).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusConflict)
})
})
})
t.Run("adminDeleteQuotaGroup", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
createQuotaGroup(t, "default")
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
group, err := quota_model.GetGroupByName(db.DefaultContext, "default")
require.NoError(t, err)
assert.Nil(t, group)
t.Run("unhappy path", func(t *testing.T) {
t.Run("non-existing group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
})
})
t.Run("adminAddRuleToQuotaGroup", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaGroup(t, "default")()
defer createQuotaRule(t, ruleDenyAll)()
req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
group, err := quota_model.GetGroupByName(db.DefaultContext, "default")
require.NoError(t, err)
assert.Len(t, group.Rules, 1)
assert.Equal(t, "deny-all", group.Rules[0].Name)
t.Run("unhappy path", func(t *testing.T) {
t.Run("non-existing group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/does-not-exist/rules/deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("non-existing rule", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/does-not-exist").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
})
})
t.Run("adminRemoveRuleFromQuotaGroup", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaGroup(t, "default")()
defer createQuotaRule(t, ruleDenyAll)()
req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
group, err := quota_model.GetGroupByName(db.DefaultContext, "default")
require.NoError(t, err)
assert.Equal(t, "default", group.Name)
assert.Empty(t, group.Rules)
t.Run("unhappy path", func(t *testing.T) {
t.Run("non-existing group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist/rules/deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("non-existing rule", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/does-not-exist").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("rule not in group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaRule(t, api.CreateQuotaRuleOptions{
Name: "rule-not-in-group",
Limit: &zero,
})()
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/rule-not-in-group").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
})
})
t.Run("adminGetQuotaGroup", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaGroup(t, "default")()
defer createQuotaRule(t, ruleDenyAll)()
req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "GET", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken)
resp := adminSession.MakeRequest(t, req, http.StatusOK)
var q api.QuotaGroup
DecodeJSON(t, resp, &q)
assert.Equal(t, "default", q.Name)
assert.Len(t, q.Rules, 1)
assert.Equal(t, "deny-all", q.Rules[0].Name)
t.Run("unhappy path", func(t *testing.T) {
t.Run("non-existing group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/admin/quota/groups/does-not-exist").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
})
})
t.Run("adminListQuotaGroups", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaGroup(t, "default")()
defer createQuotaRule(t, ruleDenyAll)()
req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "GET", "/api/v1/admin/quota/groups").AddTokenAuth(adminToken)
resp := adminSession.MakeRequest(t, req, http.StatusOK)
var q api.QuotaGroupList
DecodeJSON(t, resp, &q)
assert.Len(t, q, 1)
assert.Equal(t, "default", q[0].Name)
assert.Len(t, q[0].Rules, 1)
assert.Equal(t, "deny-all", q[0].Rules[0].Name)
})
t.Run("adminAddUserToQuotaGroup", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaGroup(t, "default")()
req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID)
require.NoError(t, err)
assert.Len(t, groups, 1)
assert.Equal(t, "default", groups[0].Name)
t.Run("unhappy path", func(t *testing.T) {
t.Run("non-existing group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/does-not-exist/users/%s", username).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("non-existing user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/this-user-does-not-exist").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("user already added", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusConflict)
})
})
})
t.Run("adminRemoveUserFromQuotaGroup", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaGroup(t, "default")()
req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID)
require.NoError(t, err)
assert.Empty(t, groups)
t.Run("unhappy path", func(t *testing.T) {
t.Run("non-existing group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist/users/%s", username).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("non-existing user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/users/does-not-exist").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("user not in group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
})
})
t.Run("adminListUsersInQuotaGroup", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaGroup(t, "default")()
req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "GET", "/api/v1/admin/quota/groups/default/users").AddTokenAuth(adminToken)
resp := adminSession.MakeRequest(t, req, http.StatusOK)
var q []api.User
DecodeJSON(t, resp, &q)
assert.Len(t, q, 1)
assert.Equal(t, username, q[0].UserName)
t.Run("unhappy path", func(t *testing.T) {
t.Run("non-existing group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/admin/quota/groups/does-not-exist/users").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
})
})
t.Run("adminSetUserQuotaGroups", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer createQuotaGroup(t, "default")()
defer createQuotaGroup(t, "test-1")()
defer createQuotaGroup(t, "test-2")()
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/admin/users/%s/quota/groups", username), api.SetUserQuotaGroupsOptions{
Groups: &[]string{"default", "test-1", "test-2"},
}).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID)
require.NoError(t, err)
assert.Len(t, groups, 3)
t.Run("unhappy path", func(t *testing.T) {
t.Run("non-existing user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/does-not-exist/quota/groups", api.SetUserQuotaGroupsOptions{
Groups: &[]string{"default", "test-1", "test-2"},
}).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("non-existing group", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/admin/users/%s/quota/groups", username), api.SetUserQuotaGroupsOptions{
Groups: &[]string{"default", "test-1", "test-2", "this-group-does-not-exist"},
}).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
})
})
}
func TestAPIQuotaUserRoutes(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Quota.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
adminSession := loginUser(t, admin.Name)
adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
// Create a test user
username := "quota-test-user-routes"
defer apiCreateUser(t, username)()
session := loginUser(t, username)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
// Set up rules & groups for the user
defer createQuotaGroup(t, "user-routes-deny")()
defer createQuotaGroup(t, "user-routes-1kb")()
zero := int64(0)
ruleDenyAll := api.CreateQuotaRuleOptions{
Name: "user-routes-deny-all",
Limit: &zero,
Subjects: []string{"size:all"},
}
defer createQuotaRule(t, ruleDenyAll)()
oneKb := int64(1024)
rule1KbStuff := api.CreateQuotaRuleOptions{
Name: "user-routes-1kb",
Limit: &oneKb,
Subjects: []string{"size:assets:attachments:releases", "size:assets:packages:all", "size:git:lfs"},
}
defer createQuotaRule(t, rule1KbStuff)()
req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/user-routes-deny/rules/user-routes-deny-all").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequest(t, "PUT", "/api/v1/admin/quota/groups/user-routes-1kb/rules/user-routes-1kb").AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/user-routes-deny/users/%s", username).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
req = NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/user-routes-1kb/users/%s", username).AddTokenAuth(adminToken)
adminSession.MakeRequest(t, req, http.StatusNoContent)
t.Run("userGetQuota", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
var q api.QuotaInfo
DecodeJSON(t, resp, &q)
assert.Len(t, q.Groups, 2)
assert.Len(t, q.Groups[0].Rules, 1)
assert.Len(t, q.Groups[1].Rules, 1)
})
}

File diff suppressed because it is too large Load diff

View file

@ -701,6 +701,7 @@ type DeclarativeRepoOptions struct {
Files optional.Option[[]*files_service.ChangeRepoFile]
WikiBranch optional.Option[string]
AutoInit optional.Option[bool]
IsTemplate optional.Option[bool]
}
func CreateDeclarativeRepoWithOptions(t *testing.T, owner *user_model.User, opts DeclarativeRepoOptions) (*repo_model.Repository, string, func()) {
@ -731,6 +732,7 @@ func CreateDeclarativeRepoWithOptions(t *testing.T, owner *user_model.User, opts
License: "WTFPL",
Readme: "Default",
DefaultBranch: "main",
IsTemplate: opts.IsTemplate.Value(),
})
require.NoError(t, err)
assert.NotEmpty(t, repo)

File diff suppressed because it is too large Load diff