diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index 5ca12a99e1..e9db250e75 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -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. diff --git a/models/forgejo_migrations/v20.go b/models/forgejo_migrations/v20.go new file mode 100644 index 0000000000..8ca9e91f73 --- /dev/null +++ b/models/forgejo_migrations/v20.go @@ -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)) +} diff --git a/models/quota/errors.go b/models/quota/errors.go new file mode 100644 index 0000000000..962c8b1cca --- /dev/null +++ b/models/quota/errors.go @@ -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) +} diff --git a/models/quota/group.go b/models/quota/group.go new file mode 100644 index 0000000000..045a98ec21 --- /dev/null +++ b/models/quota/group.go @@ -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 +} diff --git a/models/quota/limit_subject.go b/models/quota/limit_subject.go new file mode 100644 index 0000000000..4a49d33575 --- /dev/null +++ b/models/quota/limit_subject.go @@ -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 "" +} + +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 +} diff --git a/models/quota/quota.go b/models/quota/quota.go new file mode 100644 index 0000000000..d38bfab3cc --- /dev/null +++ b/models/quota/quota.go @@ -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 +} diff --git a/models/quota/quota_group_test.go b/models/quota/quota_group_test.go new file mode 100644 index 0000000000..bc258588f9 --- /dev/null +++ b/models/quota/quota_group_test.go @@ -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) +} diff --git a/models/quota/quota_rule_test.go b/models/quota/quota_rule_test.go new file mode 100644 index 0000000000..1e1daf4c4a --- /dev/null +++ b/models/quota/quota_rule_test.go @@ -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) + }) +} diff --git a/models/quota/rule.go b/models/quota/rule.go new file mode 100644 index 0000000000..b0c6c0f4b6 --- /dev/null +++ b/models/quota/rule.go @@ -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() +} diff --git a/models/quota/used.go b/models/quota/used.go new file mode 100644 index 0000000000..ff84ac20f8 --- /dev/null +++ b/models/quota/used.go @@ -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 +} diff --git a/modules/setting/quota.go b/modules/setting/quota.go new file mode 100644 index 0000000000..6dfadb59f6 --- /dev/null +++ b/modules/setting/quota.go @@ -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) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index a873b92cdf..892e41cddf 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -155,6 +155,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadGitFrom(cfg) loadMirrorFrom(cfg) loadMarkupFrom(cfg) + loadQuotaFrom(cfg) loadOtherFrom(cfg) return nil } diff --git a/modules/structs/quota.go b/modules/structs/quota.go new file mode 100644 index 0000000000..cb8874ab0c --- /dev/null +++ b/modules/structs/quota.go @@ -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"` +} diff --git a/routers/api/v1/admin/quota.go b/routers/api/v1/admin/quota.go new file mode 100644 index 0000000000..1e7c11e007 --- /dev/null +++ b/routers/api/v1/admin/quota.go @@ -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) +} diff --git a/routers/api/v1/admin/quota_group.go b/routers/api/v1/admin/quota_group.go new file mode 100644 index 0000000000..b75bdef54b --- /dev/null +++ b/routers/api/v1/admin/quota_group.go @@ -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) +} diff --git a/routers/api/v1/admin/quota_rule.go b/routers/api/v1/admin/quota_rule.go new file mode 100644 index 0000000000..85c05e1e9b --- /dev/null +++ b/routers/api/v1/admin/quota_rule.go @@ -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) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 7757f401a7..263ee5fdc4 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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 @@ -892,6 +892,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) @@ -1482,6 +1491,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 +1550,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 +1577,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() { diff --git a/routers/api/v1/org/quota.go b/routers/api/v1/org/quota.go new file mode 100644 index 0000000000..57c41f5ce3 --- /dev/null +++ b/routers/api/v1/org/quota.go @@ -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) +} diff --git a/routers/api/v1/shared/quota.go b/routers/api/v1/shared/quota.go new file mode 100644 index 0000000000..b892df4b2f --- /dev/null +++ b/routers/api/v1/shared/quota.go @@ -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) +} diff --git a/routers/api/v1/swagger/misc.go b/routers/api/v1/swagger/misc.go index df8a813dfb..0553eac2a9 100644 --- a/routers/api/v1/swagger/misc.go +++ b/routers/api/v1/swagger/misc.go @@ -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"` +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index c34470030b..3034b09ce3 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -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 } diff --git a/routers/api/v1/swagger/quota.go b/routers/api/v1/swagger/quota.go new file mode 100644 index 0000000000..35e633c39d --- /dev/null +++ b/routers/api/v1/swagger/quota.go @@ -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"` +} diff --git a/routers/api/v1/user/quota.go b/routers/api/v1/user/quota.go new file mode 100644 index 0000000000..573d7b7fbc --- /dev/null +++ b/routers/api/v1/user/quota.go @@ -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) +} diff --git a/services/context/api.go b/services/context/api.go index fafd49fd42..89078ebf66 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -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() { diff --git a/services/context/quota.go b/services/context/quota.go new file mode 100644 index 0000000000..1022e7453a --- /dev/null +++ b/services/context/quota.go @@ -0,0 +1,44 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "net/http" + + quota_model "code.gitea.io/gitea/models/quota" +) + +// 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 + } +} diff --git a/services/convert/quota.go b/services/convert/quota.go new file mode 100644 index 0000000000..791cd8e038 --- /dev/null +++ b/services/convert/quota.go @@ -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 +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 47b40b6d8a..ffb4f34bb0 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -451,6 +451,513 @@ } } }, + "/admin/quota/groups": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "List the available quota groups", + "operationId": "adminListQuotaGroups", + "responses": { + "200": { + "$ref": "#/responses/QuotaGroupList" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Create a new quota group", + "operationId": "adminCreateQuotaGroup", + "parameters": [ + { + "description": "Definition of the quota group", + "name": "group", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateQuotaGroupOptions" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/QuotaGroup" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "409": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/admin/quota/groups/{quotagroup}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get information about the quota group", + "operationId": "adminGetQuotaGroup", + "parameters": [ + { + "type": "string", + "description": "quota group to query", + "name": "quotagroup", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaGroup" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete a quota group", + "operationId": "adminDeleteQuotaGroup", + "parameters": [ + { + "type": "string", + "description": "quota group to delete", + "name": "quotagroup", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/admin/quota/groups/{quotagroup}/rules/{quotarule}": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Adds a rule to a quota group", + "operationId": "adminAddRuleToQuotaGroup", + "parameters": [ + { + "type": "string", + "description": "quota group to add a rule to", + "name": "quotagroup", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the name of the quota rule to add to the group", + "name": "quotarule", + "in": "path", + "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" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Removes a rule from a quota group", + "operationId": "adminRemoveRuleFromQuotaGroup", + "parameters": [ + { + "type": "string", + "description": "quota group to add a rule to", + "name": "quotagroup", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the name of the quota rule to remove from the group", + "name": "quotarule", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/admin/quota/groups/{quotagroup}/users": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "List users in a quota group", + "operationId": "adminListUsersInQuotaGroup", + "parameters": [ + { + "type": "string", + "description": "quota group to list members of", + "name": "quotagroup", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/UserList" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/admin/quota/groups/{quotagroup}/users/{username}": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Add a user to a quota group", + "operationId": "adminAddUserToQuotaGroup", + "parameters": [ + { + "type": "string", + "description": "quota group to add the user to", + "name": "quotagroup", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "username of the user to add to the quota group", + "name": "username", + "in": "path", + "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" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Remove a user from a quota group", + "operationId": "adminRemoveUserFromQuotaGroup", + "parameters": [ + { + "type": "string", + "description": "quota group to remove a user from", + "name": "quotagroup", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "username of the user to add to the quota group", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/admin/quota/rules": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "List the available quota rules", + "operationId": "adminListQuotaRules", + "responses": { + "200": { + "$ref": "#/responses/QuotaRuleInfoList" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + }, + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Create a new quota rule", + "operationId": "adminCreateQuotaRule", + "parameters": [ + { + "description": "Definition of the quota rule", + "name": "rule", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateQuotaRuleOptions" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/QuotaRuleInfo" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "409": { + "$ref": "#/responses/error" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/admin/quota/rules/{quotarule}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get information about a quota rule", + "operationId": "adminGetQuotaRule", + "parameters": [ + { + "type": "string", + "description": "quota rule to query", + "name": "quotarule", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaRuleInfo" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Deletes a quota rule", + "operationId": "adminDEleteQuotaRule", + "parameters": [ + { + "type": "string", + "description": "quota rule to delete", + "name": "quotarule", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Change an existing quota rule", + "operationId": "adminEditQuotaRule", + "parameters": [ + { + "type": "string", + "description": "Quota rule to change", + "name": "quotarule", + "in": "path", + "required": true + }, + { + "name": "rule", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/EditQuotaRuleOptions" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaRuleInfo" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/admin/runners/registration-token": { "get": { "produces": [ @@ -873,6 +1380,91 @@ } } }, + "/admin/users/{username}/quota": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get the user's quota info", + "operationId": "adminGetUserQuota", + "parameters": [ + { + "type": "string", + "description": "username of user to query", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaInfo" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/admin/users/{username}/quota/groups": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Set the user's quota groups to a given list.", + "operationId": "adminSetUserQuotaGroups", + "parameters": [ + { + "type": "string", + "description": "username of the user to add to the quota group", + "name": "username", + "in": "path", + "required": true + }, + { + "description": "quota group to remove a user from", + "name": "groups", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetUserQuotaGroupsOptions" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/admin/users/{username}/rename": { "post": { "produces": [ @@ -2867,6 +3459,205 @@ } } }, + "/orgs/{org}/quota": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Get quota information for an organization", + "operationId": "orgGetQuota", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaInfo" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/orgs/{org}/quota/artifacts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List the artifacts affecting the organization's quota", + "operationId": "orgListQuotaArtifacts", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaUsedArtifactList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/orgs/{org}/quota/attachments": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List the attachments affecting the organization's quota", + "operationId": "orgListQuotaAttachments", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaUsedAttachmentList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/orgs/{org}/quota/check": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Check if the organization is over quota for a given subject", + "operationId": "orgCheckQuota", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/boolean" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/orgs/{org}/quota/packages": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List the packages affecting the organization's quota", + "operationId": "orgListQuotaPackages", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaUsedPackageList" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/orgs/{org}/repos": { "get": { "produces": [ @@ -17507,6 +18298,151 @@ } } }, + "/user/quota": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get quota information for the authenticated user", + "operationId": "userGetQuota", + "responses": { + "200": { + "$ref": "#/responses/QuotaInfo" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, + "/user/quota/artifacts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List the artifacts affecting the authenticated user's quota", + "operationId": "userListQuotaArtifacts", + "parameters": [ + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaUsedArtifactList" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, + "/user/quota/attachments": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List the attachments affecting the authenticated user's quota", + "operationId": "userListQuotaAttachments", + "parameters": [ + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaUsedAttachmentList" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, + "/user/quota/check": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Check if the authenticated user is over quota for a given subject", + "operationId": "userCheckQuota", + "responses": { + "200": { + "$ref": "#/responses/boolean" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/user/quota/packages": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List the packages affecting the authenticated user's quota", + "operationId": "userListQuotaPackages", + "parameters": [ + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/QuotaUsedPackageList" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, "/user/repos": { "get": { "produces": [ @@ -20477,6 +21413,52 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateQuotaGroupOptions": { + "description": "CreateQutaGroupOptions represents the options for creating a quota group", + "type": "object", + "properties": { + "name": { + "description": "Name of the quota group to create", + "type": "string", + "x-go-name": "Name" + }, + "rules": { + "description": "Rules to add to the newly created group.\nIf a rule does not exist, it will be created.", + "type": "array", + "items": { + "$ref": "#/definitions/CreateQuotaRuleOptions" + }, + "x-go-name": "Rules" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "CreateQuotaRuleOptions": { + "description": "CreateQuotaRuleOptions represents the options for creating a quota rule", + "type": "object", + "properties": { + "limit": { + "description": "The limit set by the rule", + "type": "integer", + "format": "int64", + "x-go-name": "Limit" + }, + "name": { + "description": "Name of the rule to create", + "type": "string", + "x-go-name": "Name" + }, + "subjects": { + "description": "The subjects affected by the rule", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Subjects" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreateReleaseOption": { "description": "CreateReleaseOption options when creating a release", "type": "object", @@ -21431,6 +22413,27 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "EditQuotaRuleOptions": { + "description": "EditQuotaRuleOptions represents the options for editing a quota rule", + "type": "object", + "properties": { + "limit": { + "description": "The limit set by the rule", + "type": "integer", + "format": "int64", + "x-go-name": "Limit" + }, + "subjects": { + "description": "The subjects affected by the rule", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Subjects" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "EditReactionOption": { "description": "EditReactionOption contain the reaction type", "type": "object", @@ -24214,6 +25217,301 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "QuotaGroup": { + "description": "QuotaGroup represents a quota group", + "type": "object", + "properties": { + "name": { + "description": "Name of the group", + "type": "string", + "x-go-name": "Name" + }, + "rules": { + "description": "Rules associated with the group", + "type": "array", + "items": { + "$ref": "#/definitions/QuotaRuleInfo" + }, + "x-go-name": "Rules" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaGroupList": { + "description": "QuotaGroupList represents a list of quota groups", + "type": "array", + "items": { + "$ref": "#/definitions/QuotaGroup" + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaInfo": { + "description": "QuotaInfo represents information about a user's quota", + "type": "object", + "properties": { + "groups": { + "$ref": "#/definitions/QuotaGroupList" + }, + "used": { + "$ref": "#/definitions/QuotaUsed" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaRuleInfo": { + "description": "QuotaRuleInfo contains information about a quota rule", + "type": "object", + "properties": { + "limit": { + "description": "The limit set by the rule", + "type": "integer", + "format": "int64", + "x-go-name": "Limit" + }, + "name": { + "description": "Name of the rule (only shown to admins)", + "type": "string", + "x-go-name": "Name" + }, + "subjects": { + "description": "Subjects the rule affects", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Subjects" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsed": { + "description": "QuotaUsed represents the quota usage of a user", + "type": "object", + "properties": { + "size": { + "$ref": "#/definitions/QuotaUsedSize" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedArtifact": { + "description": "QuotaUsedArtifact represents an artifact counting towards a user's quota", + "type": "object", + "properties": { + "html_url": { + "description": "HTML URL to the action run containing the artifact", + "type": "string", + "x-go-name": "HTMLURL" + }, + "name": { + "description": "Name of the artifact", + "type": "string", + "x-go-name": "Name" + }, + "size": { + "description": "Size of the artifact (compressed)", + "type": "integer", + "format": "int64", + "x-go-name": "Size" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedArtifactList": { + "description": "QuotaUsedArtifactList represents a list of artifacts counting towards a user's quota", + "type": "array", + "items": { + "$ref": "#/definitions/QuotaUsedArtifact" + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedAttachment": { + "description": "QuotaUsedAttachment represents an attachment counting towards a user's quota", + "type": "object", + "properties": { + "api_url": { + "description": "API URL for the attachment", + "type": "string", + "x-go-name": "APIURL" + }, + "contained_in": { + "description": "Context for the attachment: URLs to the containing object", + "type": "object", + "properties": { + "api_url": { + "description": "API URL for the object that contains this attachment", + "type": "string", + "x-go-name": "APIURL" + }, + "html_url": { + "description": "HTML URL for the object that contains this attachment", + "type": "string", + "x-go-name": "HTMLURL" + } + }, + "x-go-name": "ContainedIn" + }, + "name": { + "description": "Filename of the attachment", + "type": "string", + "x-go-name": "Name" + }, + "size": { + "description": "Size of the attachment (in bytes)", + "type": "integer", + "format": "int64", + "x-go-name": "Size" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedAttachmentList": { + "description": "QuotaUsedAttachmentList represents a list of attachment counting towards a user's quota", + "type": "array", + "items": { + "$ref": "#/definitions/QuotaUsedAttachment" + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedPackage": { + "description": "QuotaUsedPackage represents a package counting towards a user's quota", + "type": "object", + "properties": { + "html_url": { + "description": "HTML URL to the package version", + "type": "string", + "x-go-name": "HTMLURL" + }, + "name": { + "description": "Name of the package", + "type": "string", + "x-go-name": "Name" + }, + "size": { + "description": "Size of the package version", + "type": "integer", + "format": "int64", + "x-go-name": "Size" + }, + "type": { + "description": "Type of the package", + "type": "string", + "x-go-name": "Type" + }, + "version": { + "description": "Version of the package", + "type": "string", + "x-go-name": "Version" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedPackageList": { + "description": "QuotaUsedPackageList represents a list of packages counting towards a user's quota", + "type": "array", + "items": { + "$ref": "#/definitions/QuotaUsedPackage" + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedSize": { + "description": "QuotaUsedSize represents the size-based quota usage of a user", + "type": "object", + "properties": { + "assets": { + "$ref": "#/definitions/QuotaUsedSizeAssets" + }, + "git": { + "$ref": "#/definitions/QuotaUsedSizeGit" + }, + "repos": { + "$ref": "#/definitions/QuotaUsedSizeRepos" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedSizeAssets": { + "description": "QuotaUsedSizeAssets represents the size-based asset usage of a user", + "type": "object", + "properties": { + "artifacts": { + "description": "Storage size used for the user's artifacts", + "type": "integer", + "format": "int64", + "x-go-name": "Artifacts" + }, + "attachments": { + "$ref": "#/definitions/QuotaUsedSizeAssetsAttachments" + }, + "packages": { + "$ref": "#/definitions/QuotaUsedSizeAssetsPackages" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedSizeAssetsAttachments": { + "description": "QuotaUsedSizeAssetsAttachments represents the size-based attachment quota usage of a user", + "type": "object", + "properties": { + "issues": { + "description": "Storage size used for the user's issue \u0026 comment attachments", + "type": "integer", + "format": "int64", + "x-go-name": "Issues" + }, + "releases": { + "description": "Storage size used for the user's release attachments", + "type": "integer", + "format": "int64", + "x-go-name": "Releases" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedSizeAssetsPackages": { + "description": "QuotaUsedSizeAssetsPackages represents the size-based package quota usage of a user", + "type": "object", + "properties": { + "all": { + "description": "Storage suze used for the user's packages", + "type": "integer", + "format": "int64", + "x-go-name": "All" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedSizeGit": { + "description": "QuotaUsedSizeGit represents the size-based git (lfs) quota usage of a user", + "type": "object", + "properties": { + "LFS": { + "description": "Storage size of the user's Git LFS objects", + "type": "integer", + "format": "int64" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "QuotaUsedSizeRepos": { + "description": "QuotaUsedSizeRepos represents the size-based repository quota usage of a user", + "type": "object", + "properties": { + "private": { + "description": "Storage size of the user's private repositories", + "type": "integer", + "format": "int64", + "x-go-name": "Private" + }, + "public": { + "description": "Storage size of the user's public repositories", + "type": "integer", + "format": "int64", + "x-go-name": "Public" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Reaction": { "description": "Reaction contain one reaction", "type": "object", @@ -24787,6 +26085,24 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "SetUserQuotaGroupsOptions": { + "description": "SetUserQuotaGroupsOptions represents the quota groups of a user", + "type": "object", + "required": [ + "groups" + ], + "properties": { + "groups": { + "description": "Quota groups the user shall have", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Groups" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "StateType": { "description": "StateType issue state type", "type": "string", @@ -26390,6 +27706,57 @@ } } }, + "QuotaGroup": { + "description": "QuotaGroup", + "schema": { + "$ref": "#/definitions/QuotaGroup" + } + }, + "QuotaGroupList": { + "description": "QuotaGroupList", + "schema": { + "$ref": "#/definitions/QuotaGroupList" + } + }, + "QuotaInfo": { + "description": "QuotaInfo", + "schema": { + "$ref": "#/definitions/QuotaInfo" + } + }, + "QuotaRuleInfo": { + "description": "QuotaRuleInfo", + "schema": { + "$ref": "#/definitions/QuotaRuleInfo" + } + }, + "QuotaRuleInfoList": { + "description": "QuotaRuleInfoList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/QuotaRuleInfo" + } + } + }, + "QuotaUsedArtifactList": { + "description": "QuotaUsedArtifactList", + "schema": { + "$ref": "#/definitions/QuotaUsedArtifactList" + } + }, + "QuotaUsedAttachmentList": { + "description": "QuotaUsedAttachmentList", + "schema": { + "$ref": "#/definitions/QuotaUsedAttachmentList" + } + }, + "QuotaUsedPackageList": { + "description": "QuotaUsedPackageList", + "schema": { + "$ref": "#/definitions/QuotaUsedPackageList" + } + }, "Reaction": { "description": "Reaction", "schema": { @@ -26689,6 +28056,9 @@ } } }, + "boolean": { + "description": "Boolean" + }, "conflict": { "description": "APIConflict is a conflict empty response" }, @@ -26737,7 +28107,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/DispatchWorkflowOption" + "$ref": "#/definitions/SetUserQuotaGroupsOptions" } }, "redirect": { diff --git a/tests/integration/api_quota_management_test.go b/tests/integration/api_quota_management_test.go new file mode 100644 index 0000000000..6337e66516 --- /dev/null +++ b/tests/integration/api_quota_management_test.go @@ -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) + }) +}