summaryrefslogtreecommitdiffstats
path: root/models/quota
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /models/quota
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--models/quota/default.go25
-rw-r--r--models/quota/errors.go127
-rw-r--r--models/quota/group.go401
-rw-r--r--models/quota/limit_subject.go69
-rw-r--r--models/quota/quota.go36
-rw-r--r--models/quota/quota_group_test.go208
-rw-r--r--models/quota/quota_rule_test.go304
-rw-r--r--models/quota/rule.go127
-rw-r--r--models/quota/used.go252
9 files changed, 1549 insertions, 0 deletions
diff --git a/models/quota/default.go b/models/quota/default.go
new file mode 100644
index 0000000..6b553d6
--- /dev/null
+++ b/models/quota/default.go
@@ -0,0 +1,25 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package quota
+
+import (
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func EvaluateDefault(used Used, forSubject LimitSubject) bool {
+ groups := GroupList{
+ &Group{
+ Name: "builtin-default-group",
+ Rules: []Rule{
+ {
+ Name: "builtin-default-rule",
+ Limit: setting.Quota.Default.Total,
+ Subjects: LimitSubjects{LimitSubjectSizeAll},
+ },
+ },
+ },
+ }
+
+ return groups.Evaluate(used, forSubject)
+}
diff --git a/models/quota/errors.go b/models/quota/errors.go
new file mode 100644
index 0000000..962c8b1
--- /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 0000000..0acb5b2
--- /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, use the configured defaults:
+ if gl == nil || len(*gl) == 0 {
+ return EvaluateDefault(used, forSubject)
+ }
+
+ 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 0000000..4a49d33
--- /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 "<unknown>"
+}
+
+func (subjects LimitSubjects) GoString() string {
+ return fmt.Sprintf("%T{%+v}", subjects, subjects)
+}
+
+func ParseLimitSubject(repr string) (LimitSubject, error) {
+ result, has := limitSubjectRepr[repr]
+ if !has {
+ return LimitSubjectNone, ErrParseLimitSubjectUnrecognized{Subject: repr}
+ }
+ return result, nil
+}
diff --git a/models/quota/quota.go b/models/quota/quota.go
new file mode 100644
index 0000000..d38bfab
--- /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 0000000..bc25858
--- /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 0000000..1e1daf4
--- /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 0000000..b0c6c0f
--- /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 0000000..ff84ac2
--- /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
+}