From e1fe3bbdc0083b9af54d96396b1125d2a25ee8c5 Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Sat, 6 Jul 2024 10:25:41 +0200 Subject: [PATCH] feat(quota): Humble beginnings of a quota engine This is an implementation of a quota engine, and the API routes to manage its settings. This does *not* contain any enforcement code: this is just the bedrock, the engine itself. The goal of the engine is to be flexible and future proof: to be nimble enough to build on it further, without having to rewrite large parts of it. It might feel a little more complicated than necessary, because the goal was to be able to support scenarios only very few Forgejo instances need, scenarios the vast majority of mostly smaller instances simply do not care about. The goal is to support both big and small, and for that, we need a solid, flexible foundation. There are thee big parts to the engine: counting quota use, setting limits, and evaluating whether the usage is within the limits. Sounds simple on paper, less so in practice! Quota counting ============== Quota is counted based on repo ownership, whenever possible, because repo owners are in ultimate control over the resources they use: they can delete repos, attachments, everything, even if they don't *own* those themselves. They can clean up, and will always have the permission and access required to do so. Would we count quota based on the owning user, that could lead to situations where a user is unable to free up space, because they uploaded a big attachment to a repo that has been taken private since. It's both more fair, and much safer to count quota against repo owners. This means that if user A uploads an attachment to an issue opened against organization O, that will count towards the quota of organization O, rather than user A. One's quota usage stats can be queried using the `/user/quota` API endpoint. To figure out what's eating into it, the `/user/repos?order_by=size`, `/user/quota/attachments`, `/user/quota/artifacts`, and `/user/quota/packages` endpoints should be consulted. There's also `/user/quota/check?subject=<...>` to check whether the signed-in user is within a particular quota limit. Quotas are counted based on sizes stored in the database. Setting quota limits ==================== There are different "subjects" one can limit usage for. At this time, only size-based limits are implemented, which are: - `size:all`: As the name would imply, the total size of everything Forgejo tracks. - `size:repos:all`: The total size of all repositories (not including LFS). - `size:repos:public`: The total size of all public repositories (not including LFS). - `size:repos:private`: The total size of all private repositories (not including LFS). - `size:git:all`: The total size of all git data (including all repositories, and LFS). - `size:git:lfs`: The size of all git LFS data (either in private or public repos). - `size:assets:all`: The size of all assets tracked by Forgejo. - `size:assets:attachments:all`: The size of all kinds of attachments tracked by Forgejo. - `size:assets:attachments:issues`: Size of all attachments attached to issues, including issue comments. - `size:assets:attachments:releases`: Size of all attachments attached to releases. This does *not* include automatically generated archives. - `size:assets:artifacts`: Size of all Action artifacts. - `size:assets:packages:all`: Size of all Packages. - `size:wiki`: Wiki size Wiki size is currently not tracked, and the engine will always deem it within quota. These subjects are built into Rules, which set a limit on *all* subjects within a rule. Thus, we can create a rule that says: "1Gb limit on all release assets, all packages, and git LFS, combined". For a rule to stand, the total sum of all subjects must be below the rule's limit. Rules are in turn collected into groups. A group is just a name, and a list of rules. For a group to stand, all of its rules must stand. Thus, if we have a group with two rules, one that sets a combined 1Gb limit on release assets, all packages, and git LFS, and another rule that sets a 256Mb limit on packages, if the user has 512Mb of packages, the group will not stand, because the second rule deems it over quota. Similarly, if the user has only 128Mb of packages, but 900Mb of release assets, the group will not stand, because the combined size of packages and release assets is over the 1Gb limit of the first rule. Groups themselves are collected into Group Lists. A group list stands when *any* of the groups within stand. This allows an administrator to set conservative defaults, but then place select users into additional groups that increase some aspect of their limits. To top it off, it is possible to set the default quota groups a user belongs to in `app.ini`. If there's no explicit assignment, the engine will use the default groups. This makes it possible to avoid having to assign each and every user a list of quota groups, and only those need to be explicitly assigned who need a different set of groups than the defaults. If a user has any quota groups assigned to them, the default list will not be considered for them. The management APIs =================== This commit contains the engine itself, its unit tests, and the quota management APIs. It does not contain any enforcement. The APIs are documented in-code, and in the swagger docs, and the integration tests can serve as an example on how to use them. Signed-off-by: Gergely Nagy --- models/forgejo_migrations/migrate.go | 2 + models/forgejo_migrations/v20.go | 52 + models/quota/errors.go | 127 ++ models/quota/group.go | 401 +++++ models/quota/limit_subject.go | 69 + models/quota/quota.go | 36 + models/quota/quota_group_test.go | 208 +++ models/quota/quota_rule_test.go | 304 ++++ models/quota/rule.go | 127 ++ models/quota/used.go | 252 +++ modules/setting/quota.go | 17 + modules/setting/setting.go | 1 + modules/structs/quota.go | 163 ++ routers/api/v1/admin/quota.go | 53 + routers/api/v1/admin/quota_group.go | 436 ++++++ routers/api/v1/admin/quota_rule.go | 219 +++ routers/api/v1/api.go | 58 +- routers/api/v1/org/quota.go | 155 ++ routers/api/v1/shared/quota.go | 102 ++ routers/api/v1/swagger/misc.go | 7 + routers/api/v1/swagger/options.go | 12 + routers/api/v1/swagger/quota.go | 64 + routers/api/v1/user/quota.go | 118 ++ services/context/api.go | 11 +- services/context/quota.go | 44 + services/convert/quota.go | 185 +++ templates/swagger/v1_json.tmpl | 1372 ++++++++++++++++- .../integration/api_quota_management_test.go | 846 ++++++++++ 28 files changed, 5435 insertions(+), 6 deletions(-) create mode 100644 models/forgejo_migrations/v20.go create mode 100644 models/quota/errors.go create mode 100644 models/quota/group.go create mode 100644 models/quota/limit_subject.go create mode 100644 models/quota/quota.go create mode 100644 models/quota/quota_group_test.go create mode 100644 models/quota/quota_rule_test.go create mode 100644 models/quota/rule.go create mode 100644 models/quota/used.go create mode 100644 modules/setting/quota.go create mode 100644 modules/structs/quota.go create mode 100644 routers/api/v1/admin/quota.go create mode 100644 routers/api/v1/admin/quota_group.go create mode 100644 routers/api/v1/admin/quota_rule.go create mode 100644 routers/api/v1/org/quota.go create mode 100644 routers/api/v1/shared/quota.go create mode 100644 routers/api/v1/swagger/quota.go create mode 100644 routers/api/v1/user/quota.go create mode 100644 services/context/quota.go create mode 100644 services/convert/quota.go create mode 100644 tests/integration/api_quota_management_test.go 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) + }) +}