loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 <forgejo@gergo.csillger.hu>

+5435 -6
+2
models/forgejo_migrations/migrate.go
··· 76 76 NewMigration("Create the `following_repo` table", CreateFollowingRepoTable), 77 77 // v19 -> v20 78 78 NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable), 79 + // v20 -> v21 80 + NewMigration("Creating Quota-related tables", CreateQuotaTables), 79 81 } 80 82 81 83 // GetCurrentDBVersion returns the current Forgejo database version.
+52
models/forgejo_migrations/v20.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgejo_migrations //nolint:revive 5 + 6 + import "xorm.io/xorm" 7 + 8 + type ( 9 + QuotaLimitSubject int 10 + QuotaLimitSubjects []QuotaLimitSubject 11 + 12 + QuotaKind int 13 + ) 14 + 15 + type QuotaRule struct { 16 + Name string `xorm:"pk not null"` 17 + Limit int64 `xorm:"NOT NULL"` 18 + Subjects QuotaLimitSubjects 19 + } 20 + 21 + type QuotaGroup struct { 22 + Name string `xorm:"pk NOT NULL"` 23 + } 24 + 25 + type QuotaGroupRuleMapping struct { 26 + ID int64 `xorm:"pk autoincr"` 27 + GroupName string `xorm:"index unique(qgrm_gr) not null"` 28 + RuleName string `xorm:"unique(qgrm_gr) not null"` 29 + } 30 + 31 + type QuotaGroupMapping struct { 32 + ID int64 `xorm:"pk autoincr"` 33 + Kind QuotaKind `xorm:"unique(qgm_kmg) not null"` 34 + MappedID int64 `xorm:"unique(qgm_kmg) not null"` 35 + GroupName string `xorm:"index unique(qgm_kmg) not null"` 36 + } 37 + 38 + func CreateQuotaTables(x *xorm.Engine) error { 39 + if err := x.Sync(new(QuotaRule)); err != nil { 40 + return err 41 + } 42 + 43 + if err := x.Sync(new(QuotaGroup)); err != nil { 44 + return err 45 + } 46 + 47 + if err := x.Sync(new(QuotaGroupRuleMapping)); err != nil { 48 + return err 49 + } 50 + 51 + return x.Sync(new(QuotaGroupMapping)) 52 + }
+127
models/quota/errors.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package quota 5 + 6 + import "fmt" 7 + 8 + type ErrRuleAlreadyExists struct { 9 + Name string 10 + } 11 + 12 + func IsErrRuleAlreadyExists(err error) bool { 13 + _, ok := err.(ErrRuleAlreadyExists) 14 + return ok 15 + } 16 + 17 + func (err ErrRuleAlreadyExists) Error() string { 18 + return fmt.Sprintf("rule already exists: [name: %s]", err.Name) 19 + } 20 + 21 + type ErrRuleNotFound struct { 22 + Name string 23 + } 24 + 25 + func IsErrRuleNotFound(err error) bool { 26 + _, ok := err.(ErrRuleNotFound) 27 + return ok 28 + } 29 + 30 + func (err ErrRuleNotFound) Error() string { 31 + return fmt.Sprintf("rule not found: [name: %s]", err.Name) 32 + } 33 + 34 + type ErrGroupAlreadyExists struct { 35 + Name string 36 + } 37 + 38 + func IsErrGroupAlreadyExists(err error) bool { 39 + _, ok := err.(ErrGroupAlreadyExists) 40 + return ok 41 + } 42 + 43 + func (err ErrGroupAlreadyExists) Error() string { 44 + return fmt.Sprintf("group already exists: [name: %s]", err.Name) 45 + } 46 + 47 + type ErrGroupNotFound struct { 48 + Name string 49 + } 50 + 51 + func IsErrGroupNotFound(err error) bool { 52 + _, ok := err.(ErrGroupNotFound) 53 + return ok 54 + } 55 + 56 + func (err ErrGroupNotFound) Error() string { 57 + return fmt.Sprintf("group not found: [group: %s]", err.Name) 58 + } 59 + 60 + type ErrUserAlreadyInGroup struct { 61 + GroupName string 62 + UserID int64 63 + } 64 + 65 + func IsErrUserAlreadyInGroup(err error) bool { 66 + _, ok := err.(ErrUserAlreadyInGroup) 67 + return ok 68 + } 69 + 70 + func (err ErrUserAlreadyInGroup) Error() string { 71 + return fmt.Sprintf("user already in group: [group: %s, userID: %d]", err.GroupName, err.UserID) 72 + } 73 + 74 + type ErrUserNotInGroup struct { 75 + GroupName string 76 + UserID int64 77 + } 78 + 79 + func IsErrUserNotInGroup(err error) bool { 80 + _, ok := err.(ErrUserNotInGroup) 81 + return ok 82 + } 83 + 84 + func (err ErrUserNotInGroup) Error() string { 85 + return fmt.Sprintf("user not in group: [group: %s, userID: %d]", err.GroupName, err.UserID) 86 + } 87 + 88 + type ErrRuleAlreadyInGroup struct { 89 + GroupName string 90 + RuleName string 91 + } 92 + 93 + func IsErrRuleAlreadyInGroup(err error) bool { 94 + _, ok := err.(ErrRuleAlreadyInGroup) 95 + return ok 96 + } 97 + 98 + func (err ErrRuleAlreadyInGroup) Error() string { 99 + return fmt.Sprintf("rule already in group: [group: %s, rule: %s]", err.GroupName, err.RuleName) 100 + } 101 + 102 + type ErrRuleNotInGroup struct { 103 + GroupName string 104 + RuleName string 105 + } 106 + 107 + func IsErrRuleNotInGroup(err error) bool { 108 + _, ok := err.(ErrRuleNotInGroup) 109 + return ok 110 + } 111 + 112 + func (err ErrRuleNotInGroup) Error() string { 113 + return fmt.Sprintf("rule not in group: [group: %s, rule: %s]", err.GroupName, err.RuleName) 114 + } 115 + 116 + type ErrParseLimitSubjectUnrecognized struct { 117 + Subject string 118 + } 119 + 120 + func IsErrParseLimitSubjectUnrecognized(err error) bool { 121 + _, ok := err.(ErrParseLimitSubjectUnrecognized) 122 + return ok 123 + } 124 + 125 + func (err ErrParseLimitSubjectUnrecognized) Error() string { 126 + return fmt.Sprintf("unrecognized quota limit subject: [subject: %s]", err.Subject) 127 + }
+401
models/quota/group.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package quota 5 + 6 + import ( 7 + "context" 8 + 9 + "code.gitea.io/gitea/models/db" 10 + user_model "code.gitea.io/gitea/models/user" 11 + "code.gitea.io/gitea/modules/setting" 12 + 13 + "xorm.io/builder" 14 + ) 15 + 16 + type ( 17 + GroupList []*Group 18 + Group struct { 19 + // Name of the quota group 20 + Name string `json:"name" xorm:"pk NOT NULL" binding:"Required"` 21 + Rules []Rule `json:"rules" xorm:"-"` 22 + } 23 + ) 24 + 25 + type GroupRuleMapping struct { 26 + ID int64 `xorm:"pk autoincr" json:"-"` 27 + GroupName string `xorm:"index unique(qgrm_gr) not null" json:"group_name"` 28 + RuleName string `xorm:"unique(qgrm_gr) not null" json:"rule_name"` 29 + } 30 + 31 + type Kind int 32 + 33 + const ( 34 + KindUser Kind = iota 35 + ) 36 + 37 + type GroupMapping struct { 38 + ID int64 `xorm:"pk autoincr"` 39 + Kind Kind `xorm:"unique(qgm_kmg) not null"` 40 + MappedID int64 `xorm:"unique(qgm_kmg) not null"` 41 + GroupName string `xorm:"index unique(qgm_kmg) not null"` 42 + } 43 + 44 + func (g *Group) TableName() string { 45 + return "quota_group" 46 + } 47 + 48 + func (grm *GroupRuleMapping) TableName() string { 49 + return "quota_group_rule_mapping" 50 + } 51 + 52 + func (ugm *GroupMapping) TableName() string { 53 + return "quota_group_mapping" 54 + } 55 + 56 + func (g *Group) LoadRules(ctx context.Context) error { 57 + return db.GetEngine(ctx).Select("`quota_rule`.*"). 58 + Table("quota_rule"). 59 + Join("INNER", "`quota_group_rule_mapping`", "`quota_group_rule_mapping`.rule_name = `quota_rule`.name"). 60 + Where("`quota_group_rule_mapping`.group_name = ?", g.Name). 61 + Find(&g.Rules) 62 + } 63 + 64 + func (g *Group) isUserInGroup(ctx context.Context, userID int64) (bool, error) { 65 + return db.GetEngine(ctx). 66 + Where("kind = ? AND mapped_id = ? AND group_name = ?", KindUser, userID, g.Name). 67 + Get(&GroupMapping{}) 68 + } 69 + 70 + func (g *Group) AddUserByID(ctx context.Context, userID int64) error { 71 + ctx, committer, err := db.TxContext(ctx) 72 + if err != nil { 73 + return err 74 + } 75 + defer committer.Close() 76 + 77 + exists, err := g.isUserInGroup(ctx, userID) 78 + if err != nil { 79 + return err 80 + } else if exists { 81 + return ErrUserAlreadyInGroup{GroupName: g.Name, UserID: userID} 82 + } 83 + 84 + _, err = db.GetEngine(ctx).Insert(&GroupMapping{ 85 + Kind: KindUser, 86 + MappedID: userID, 87 + GroupName: g.Name, 88 + }) 89 + if err != nil { 90 + return err 91 + } 92 + return committer.Commit() 93 + } 94 + 95 + func (g *Group) RemoveUserByID(ctx context.Context, userID int64) error { 96 + ctx, committer, err := db.TxContext(ctx) 97 + if err != nil { 98 + return err 99 + } 100 + defer committer.Close() 101 + 102 + exists, err := g.isUserInGroup(ctx, userID) 103 + if err != nil { 104 + return err 105 + } else if !exists { 106 + return ErrUserNotInGroup{GroupName: g.Name, UserID: userID} 107 + } 108 + 109 + _, err = db.GetEngine(ctx).Delete(&GroupMapping{ 110 + Kind: KindUser, 111 + MappedID: userID, 112 + GroupName: g.Name, 113 + }) 114 + if err != nil { 115 + return err 116 + } 117 + return committer.Commit() 118 + } 119 + 120 + func (g *Group) isRuleInGroup(ctx context.Context, ruleName string) (bool, error) { 121 + return db.GetEngine(ctx). 122 + Where("group_name = ? AND rule_name = ?", g.Name, ruleName). 123 + Get(&GroupRuleMapping{}) 124 + } 125 + 126 + func (g *Group) AddRuleByName(ctx context.Context, ruleName string) error { 127 + ctx, committer, err := db.TxContext(ctx) 128 + if err != nil { 129 + return err 130 + } 131 + defer committer.Close() 132 + 133 + exists, err := DoesRuleExist(ctx, ruleName) 134 + if err != nil { 135 + return err 136 + } else if !exists { 137 + return ErrRuleNotFound{Name: ruleName} 138 + } 139 + 140 + has, err := g.isRuleInGroup(ctx, ruleName) 141 + if err != nil { 142 + return err 143 + } else if has { 144 + return ErrRuleAlreadyInGroup{GroupName: g.Name, RuleName: ruleName} 145 + } 146 + 147 + _, err = db.GetEngine(ctx).Insert(&GroupRuleMapping{ 148 + GroupName: g.Name, 149 + RuleName: ruleName, 150 + }) 151 + if err != nil { 152 + return err 153 + } 154 + return committer.Commit() 155 + } 156 + 157 + func (g *Group) RemoveRuleByName(ctx context.Context, ruleName string) error { 158 + ctx, committer, err := db.TxContext(ctx) 159 + if err != nil { 160 + return err 161 + } 162 + defer committer.Close() 163 + 164 + exists, err := g.isRuleInGroup(ctx, ruleName) 165 + if err != nil { 166 + return err 167 + } else if !exists { 168 + return ErrRuleNotInGroup{GroupName: g.Name, RuleName: ruleName} 169 + } 170 + 171 + _, err = db.GetEngine(ctx).Delete(&GroupRuleMapping{ 172 + GroupName: g.Name, 173 + RuleName: ruleName, 174 + }) 175 + if err != nil { 176 + return err 177 + } 178 + return committer.Commit() 179 + } 180 + 181 + var affectsMap = map[LimitSubject]LimitSubjects{ 182 + LimitSubjectSizeAll: { 183 + LimitSubjectSizeReposAll, 184 + LimitSubjectSizeGitLFS, 185 + LimitSubjectSizeAssetsAll, 186 + }, 187 + LimitSubjectSizeReposAll: { 188 + LimitSubjectSizeReposPublic, 189 + LimitSubjectSizeReposPrivate, 190 + }, 191 + LimitSubjectSizeAssetsAll: { 192 + LimitSubjectSizeAssetsAttachmentsAll, 193 + LimitSubjectSizeAssetsArtifacts, 194 + LimitSubjectSizeAssetsPackagesAll, 195 + }, 196 + LimitSubjectSizeAssetsAttachmentsAll: { 197 + LimitSubjectSizeAssetsAttachmentsIssues, 198 + LimitSubjectSizeAssetsAttachmentsReleases, 199 + }, 200 + } 201 + 202 + func (g *Group) Evaluate(used Used, forSubject LimitSubject) (bool, bool) { 203 + var found bool 204 + for _, rule := range g.Rules { 205 + ok, has := rule.Evaluate(used, forSubject) 206 + if has { 207 + found = true 208 + if !ok { 209 + return false, true 210 + } 211 + } 212 + } 213 + 214 + if !found { 215 + // If Evaluation for forSubject did not succeed, try evaluating against 216 + // subjects below 217 + 218 + for _, subject := range affectsMap[forSubject] { 219 + ok, has := g.Evaluate(used, subject) 220 + if has { 221 + found = true 222 + if !ok { 223 + return false, true 224 + } 225 + } 226 + } 227 + } 228 + 229 + return true, found 230 + } 231 + 232 + func (gl *GroupList) Evaluate(used Used, forSubject LimitSubject) bool { 233 + // If there are no groups, default to success: 234 + if gl == nil || len(*gl) == 0 { 235 + return true 236 + } 237 + 238 + for _, group := range *gl { 239 + ok, has := group.Evaluate(used, forSubject) 240 + if has && ok { 241 + return true 242 + } 243 + } 244 + return false 245 + } 246 + 247 + func GetGroupByName(ctx context.Context, name string) (*Group, error) { 248 + var group Group 249 + has, err := db.GetEngine(ctx).Where("name = ?", name).Get(&group) 250 + if has { 251 + if err = group.LoadRules(ctx); err != nil { 252 + return nil, err 253 + } 254 + return &group, nil 255 + } 256 + return nil, err 257 + } 258 + 259 + func ListGroups(ctx context.Context) (GroupList, error) { 260 + var groups GroupList 261 + err := db.GetEngine(ctx).Find(&groups) 262 + return groups, err 263 + } 264 + 265 + func doesGroupExist(ctx context.Context, name string) (bool, error) { 266 + return db.GetEngine(ctx).Where("name = ?", name).Get(&Group{}) 267 + } 268 + 269 + func CreateGroup(ctx context.Context, name string) (*Group, error) { 270 + ctx, committer, err := db.TxContext(ctx) 271 + if err != nil { 272 + return nil, err 273 + } 274 + defer committer.Close() 275 + 276 + exists, err := doesGroupExist(ctx, name) 277 + if err != nil { 278 + return nil, err 279 + } else if exists { 280 + return nil, ErrGroupAlreadyExists{Name: name} 281 + } 282 + 283 + group := Group{Name: name} 284 + _, err = db.GetEngine(ctx).Insert(group) 285 + if err != nil { 286 + return nil, err 287 + } 288 + return &group, committer.Commit() 289 + } 290 + 291 + func ListUsersInGroup(ctx context.Context, name string) ([]*user_model.User, error) { 292 + group, err := GetGroupByName(ctx, name) 293 + if err != nil { 294 + return nil, err 295 + } 296 + 297 + var users []*user_model.User 298 + err = db.GetEngine(ctx).Select("`user`.*"). 299 + Table("user"). 300 + Join("INNER", "`quota_group_mapping`", "`quota_group_mapping`.mapped_id = `user`.id"). 301 + Where("`quota_group_mapping`.kind = ? AND `quota_group_mapping`.group_name = ?", KindUser, group.Name). 302 + Find(&users) 303 + return users, err 304 + } 305 + 306 + func DeleteGroupByName(ctx context.Context, name string) error { 307 + ctx, committer, err := db.TxContext(ctx) 308 + if err != nil { 309 + return err 310 + } 311 + defer committer.Close() 312 + 313 + _, err = db.GetEngine(ctx).Delete(GroupMapping{ 314 + GroupName: name, 315 + }) 316 + if err != nil { 317 + return err 318 + } 319 + _, err = db.GetEngine(ctx).Delete(GroupRuleMapping{ 320 + GroupName: name, 321 + }) 322 + if err != nil { 323 + return err 324 + } 325 + 326 + _, err = db.GetEngine(ctx).Delete(Group{Name: name}) 327 + if err != nil { 328 + return err 329 + } 330 + return committer.Commit() 331 + } 332 + 333 + func SetUserGroups(ctx context.Context, userID int64, groups *[]string) error { 334 + ctx, committer, err := db.TxContext(ctx) 335 + if err != nil { 336 + return err 337 + } 338 + defer committer.Close() 339 + 340 + // First: remove the user from any groups 341 + _, err = db.GetEngine(ctx).Where("kind = ? AND mapped_id = ?", KindUser, userID).Delete(GroupMapping{}) 342 + if err != nil { 343 + return err 344 + } 345 + 346 + if groups == nil { 347 + return nil 348 + } 349 + 350 + // Then add the user to each group listed 351 + for _, groupName := range *groups { 352 + group, err := GetGroupByName(ctx, groupName) 353 + if err != nil { 354 + return err 355 + } 356 + if group == nil { 357 + return ErrGroupNotFound{Name: groupName} 358 + } 359 + err = group.AddUserByID(ctx, userID) 360 + if err != nil { 361 + return err 362 + } 363 + } 364 + 365 + return committer.Commit() 366 + } 367 + 368 + func GetGroupsForUser(ctx context.Context, userID int64) (GroupList, error) { 369 + var groups GroupList 370 + err := db.GetEngine(ctx). 371 + Where(builder.In("name", 372 + builder.Select("group_name"). 373 + From("quota_group_mapping"). 374 + Where(builder.And( 375 + builder.Eq{"kind": KindUser}, 376 + builder.Eq{"mapped_id": userID}), 377 + ))). 378 + Find(&groups) 379 + if err != nil { 380 + return nil, err 381 + } 382 + 383 + if len(groups) == 0 { 384 + err = db.GetEngine(ctx).Where(builder.In("name", setting.Quota.DefaultGroups)).Find(&groups) 385 + if err != nil { 386 + return nil, err 387 + } 388 + if len(groups) == 0 { 389 + return nil, nil 390 + } 391 + } 392 + 393 + for _, group := range groups { 394 + err = group.LoadRules(ctx) 395 + if err != nil { 396 + return nil, err 397 + } 398 + } 399 + 400 + return groups, nil 401 + }
+69
models/quota/limit_subject.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package quota 5 + 6 + import "fmt" 7 + 8 + type ( 9 + LimitSubject int 10 + LimitSubjects []LimitSubject 11 + ) 12 + 13 + const ( 14 + LimitSubjectNone LimitSubject = iota 15 + LimitSubjectSizeAll 16 + LimitSubjectSizeReposAll 17 + LimitSubjectSizeReposPublic 18 + LimitSubjectSizeReposPrivate 19 + LimitSubjectSizeGitAll 20 + LimitSubjectSizeGitLFS 21 + LimitSubjectSizeAssetsAll 22 + LimitSubjectSizeAssetsAttachmentsAll 23 + LimitSubjectSizeAssetsAttachmentsIssues 24 + LimitSubjectSizeAssetsAttachmentsReleases 25 + LimitSubjectSizeAssetsArtifacts 26 + LimitSubjectSizeAssetsPackagesAll 27 + LimitSubjectSizeWiki 28 + 29 + LimitSubjectFirst = LimitSubjectSizeAll 30 + LimitSubjectLast = LimitSubjectSizeWiki 31 + ) 32 + 33 + var limitSubjectRepr = map[string]LimitSubject{ 34 + "none": LimitSubjectNone, 35 + "size:all": LimitSubjectSizeAll, 36 + "size:repos:all": LimitSubjectSizeReposAll, 37 + "size:repos:public": LimitSubjectSizeReposPublic, 38 + "size:repos:private": LimitSubjectSizeReposPrivate, 39 + "size:git:all": LimitSubjectSizeGitAll, 40 + "size:git:lfs": LimitSubjectSizeGitLFS, 41 + "size:assets:all": LimitSubjectSizeAssetsAll, 42 + "size:assets:attachments:all": LimitSubjectSizeAssetsAttachmentsAll, 43 + "size:assets:attachments:issues": LimitSubjectSizeAssetsAttachmentsIssues, 44 + "size:assets:attachments:releases": LimitSubjectSizeAssetsAttachmentsReleases, 45 + "size:assets:artifacts": LimitSubjectSizeAssetsArtifacts, 46 + "size:assets:packages:all": LimitSubjectSizeAssetsPackagesAll, 47 + "size:assets:wiki": LimitSubjectSizeWiki, 48 + } 49 + 50 + func (subject LimitSubject) String() string { 51 + for repr, limit := range limitSubjectRepr { 52 + if limit == subject { 53 + return repr 54 + } 55 + } 56 + return "<unknown>" 57 + } 58 + 59 + func (subjects LimitSubjects) GoString() string { 60 + return fmt.Sprintf("%T{%+v}", subjects, subjects) 61 + } 62 + 63 + func ParseLimitSubject(repr string) (LimitSubject, error) { 64 + result, has := limitSubjectRepr[repr] 65 + if !has { 66 + return LimitSubjectNone, ErrParseLimitSubjectUnrecognized{Subject: repr} 67 + } 68 + return result, nil 69 + }
+36
models/quota/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package quota 5 + 6 + import ( 7 + "context" 8 + 9 + "code.gitea.io/gitea/models/db" 10 + "code.gitea.io/gitea/modules/setting" 11 + ) 12 + 13 + func init() { 14 + db.RegisterModel(new(Rule)) 15 + db.RegisterModel(new(Group)) 16 + db.RegisterModel(new(GroupRuleMapping)) 17 + db.RegisterModel(new(GroupMapping)) 18 + } 19 + 20 + func EvaluateForUser(ctx context.Context, userID int64, subject LimitSubject) (bool, error) { 21 + if !setting.Quota.Enabled { 22 + return true, nil 23 + } 24 + 25 + groups, err := GetGroupsForUser(ctx, userID) 26 + if err != nil { 27 + return false, err 28 + } 29 + 30 + used, err := GetUsedForUser(ctx, userID) 31 + if err != nil { 32 + return false, err 33 + } 34 + 35 + return groups.Evaluate(*used, subject), nil 36 + }
+208
models/quota/quota_group_test.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package quota_test 5 + 6 + import ( 7 + "testing" 8 + 9 + quota_model "code.gitea.io/gitea/models/quota" 10 + 11 + "github.com/stretchr/testify/assert" 12 + ) 13 + 14 + func TestQuotaGroupAllRulesMustPass(t *testing.T) { 15 + unlimitedRule := quota_model.Rule{ 16 + Limit: -1, 17 + Subjects: quota_model.LimitSubjects{ 18 + quota_model.LimitSubjectSizeAll, 19 + }, 20 + } 21 + denyRule := quota_model.Rule{ 22 + Limit: 0, 23 + Subjects: quota_model.LimitSubjects{ 24 + quota_model.LimitSubjectSizeAll, 25 + }, 26 + } 27 + group := quota_model.Group{ 28 + Rules: []quota_model.Rule{ 29 + unlimitedRule, 30 + denyRule, 31 + }, 32 + } 33 + 34 + used := quota_model.Used{} 35 + used.Size.Repos.Public = 1024 36 + 37 + // Within a group, *all* rules must pass. Thus, if we have a deny-all rule, 38 + // and an unlimited rule, that will always fail. 39 + ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeAll) 40 + assert.True(t, has) 41 + assert.False(t, ok) 42 + } 43 + 44 + func TestQuotaGroupRuleScenario1(t *testing.T) { 45 + group := quota_model.Group{ 46 + Rules: []quota_model.Rule{ 47 + { 48 + Limit: 1024, 49 + Subjects: quota_model.LimitSubjects{ 50 + quota_model.LimitSubjectSizeAssetsAttachmentsReleases, 51 + quota_model.LimitSubjectSizeGitLFS, 52 + quota_model.LimitSubjectSizeAssetsPackagesAll, 53 + }, 54 + }, 55 + { 56 + Limit: 0, 57 + Subjects: quota_model.LimitSubjects{ 58 + quota_model.LimitSubjectSizeGitLFS, 59 + }, 60 + }, 61 + }, 62 + } 63 + 64 + used := quota_model.Used{} 65 + used.Size.Assets.Attachments.Releases = 512 66 + used.Size.Assets.Packages.All = 256 67 + used.Size.Git.LFS = 16 68 + 69 + ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeAssetsAttachmentsReleases) 70 + assert.True(t, has, "size:assets:attachments:releases is covered") 71 + assert.True(t, ok, "size:assets:attachments:releases passes") 72 + 73 + ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll) 74 + assert.True(t, has, "size:assets:packages:all is covered") 75 + assert.True(t, ok, "size:assets:packages:all passes") 76 + 77 + ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS) 78 + assert.True(t, has, "size:git:lfs is covered") 79 + assert.False(t, ok, "size:git:lfs fails") 80 + 81 + ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAll) 82 + assert.True(t, has, "size:all is covered") 83 + assert.False(t, ok, "size:all fails") 84 + } 85 + 86 + func TestQuotaGroupRuleCombination(t *testing.T) { 87 + repoRule := quota_model.Rule{ 88 + Limit: 4096, 89 + Subjects: quota_model.LimitSubjects{ 90 + quota_model.LimitSubjectSizeReposAll, 91 + }, 92 + } 93 + packagesRule := quota_model.Rule{ 94 + Limit: 0, 95 + Subjects: quota_model.LimitSubjects{ 96 + quota_model.LimitSubjectSizeAssetsPackagesAll, 97 + }, 98 + } 99 + 100 + used := quota_model.Used{} 101 + used.Size.Repos.Public = 1024 102 + used.Size.Assets.Packages.All = 1024 103 + 104 + group := quota_model.Group{ 105 + Rules: []quota_model.Rule{ 106 + repoRule, 107 + packagesRule, 108 + }, 109 + } 110 + 111 + // Git LFS isn't covered by any rule 112 + _, has := group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS) 113 + assert.False(t, has) 114 + 115 + // repos:all is covered, and is passing 116 + ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeReposAll) 117 + assert.True(t, has) 118 + assert.True(t, ok) 119 + 120 + // packages:all is covered, and is failing 121 + ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll) 122 + assert.True(t, has) 123 + assert.False(t, ok) 124 + 125 + // size:all is covered, and is failing (due to packages:all being over quota) 126 + ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAll) 127 + assert.True(t, has, "size:all should be covered") 128 + assert.False(t, ok, "size:all should fail") 129 + } 130 + 131 + func TestQuotaGroupListsRequireOnlyOnePassing(t *testing.T) { 132 + unlimitedRule := quota_model.Rule{ 133 + Limit: -1, 134 + Subjects: quota_model.LimitSubjects{ 135 + quota_model.LimitSubjectSizeAll, 136 + }, 137 + } 138 + denyRule := quota_model.Rule{ 139 + Limit: 0, 140 + Subjects: quota_model.LimitSubjects{ 141 + quota_model.LimitSubjectSizeAll, 142 + }, 143 + } 144 + 145 + denyGroup := quota_model.Group{ 146 + Rules: []quota_model.Rule{ 147 + denyRule, 148 + }, 149 + } 150 + unlimitedGroup := quota_model.Group{ 151 + Rules: []quota_model.Rule{ 152 + unlimitedRule, 153 + }, 154 + } 155 + 156 + groups := quota_model.GroupList{&denyGroup, &unlimitedGroup} 157 + 158 + used := quota_model.Used{} 159 + used.Size.Repos.Public = 1024 160 + 161 + // In a group list, if any group passes, the entire evaluation passes. 162 + ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll) 163 + assert.True(t, ok) 164 + } 165 + 166 + func TestQuotaGroupListAllFailing(t *testing.T) { 167 + denyRule := quota_model.Rule{ 168 + Limit: 0, 169 + Subjects: quota_model.LimitSubjects{ 170 + quota_model.LimitSubjectSizeAll, 171 + }, 172 + } 173 + limitedRule := quota_model.Rule{ 174 + Limit: 1024, 175 + Subjects: quota_model.LimitSubjects{ 176 + quota_model.LimitSubjectSizeAll, 177 + }, 178 + } 179 + 180 + denyGroup := quota_model.Group{ 181 + Rules: []quota_model.Rule{ 182 + denyRule, 183 + }, 184 + } 185 + limitedGroup := quota_model.Group{ 186 + Rules: []quota_model.Rule{ 187 + limitedRule, 188 + }, 189 + } 190 + 191 + groups := quota_model.GroupList{&denyGroup, &limitedGroup} 192 + 193 + used := quota_model.Used{} 194 + used.Size.Repos.Public = 2048 195 + 196 + ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll) 197 + assert.False(t, ok) 198 + } 199 + 200 + func TestQuotaGroupListEmpty(t *testing.T) { 201 + groups := quota_model.GroupList{} 202 + 203 + used := quota_model.Used{} 204 + used.Size.Repos.Public = 2048 205 + 206 + ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll) 207 + assert.True(t, ok) 208 + }
+304
models/quota/quota_rule_test.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package quota_test 5 + 6 + import ( 7 + "testing" 8 + 9 + quota_model "code.gitea.io/gitea/models/quota" 10 + 11 + "github.com/stretchr/testify/assert" 12 + ) 13 + 14 + func makeFullyUsed() quota_model.Used { 15 + return quota_model.Used{ 16 + Size: quota_model.UsedSize{ 17 + Repos: quota_model.UsedSizeRepos{ 18 + Public: 1024, 19 + Private: 1024, 20 + }, 21 + Git: quota_model.UsedSizeGit{ 22 + LFS: 1024, 23 + }, 24 + Assets: quota_model.UsedSizeAssets{ 25 + Attachments: quota_model.UsedSizeAssetsAttachments{ 26 + Issues: 1024, 27 + Releases: 1024, 28 + }, 29 + Artifacts: 1024, 30 + Packages: quota_model.UsedSizeAssetsPackages{ 31 + All: 1024, 32 + }, 33 + }, 34 + }, 35 + } 36 + } 37 + 38 + func makePartiallyUsed() quota_model.Used { 39 + return quota_model.Used{ 40 + Size: quota_model.UsedSize{ 41 + Repos: quota_model.UsedSizeRepos{ 42 + Public: 1024, 43 + }, 44 + Assets: quota_model.UsedSizeAssets{ 45 + Attachments: quota_model.UsedSizeAssetsAttachments{ 46 + Releases: 1024, 47 + }, 48 + }, 49 + }, 50 + } 51 + } 52 + 53 + func setUsed(used quota_model.Used, subject quota_model.LimitSubject, value int64) *quota_model.Used { 54 + switch subject { 55 + case quota_model.LimitSubjectSizeReposPublic: 56 + used.Size.Repos.Public = value 57 + return &used 58 + case quota_model.LimitSubjectSizeReposPrivate: 59 + used.Size.Repos.Private = value 60 + return &used 61 + case quota_model.LimitSubjectSizeGitLFS: 62 + used.Size.Git.LFS = value 63 + return &used 64 + case quota_model.LimitSubjectSizeAssetsAttachmentsIssues: 65 + used.Size.Assets.Attachments.Issues = value 66 + return &used 67 + case quota_model.LimitSubjectSizeAssetsAttachmentsReleases: 68 + used.Size.Assets.Attachments.Releases = value 69 + return &used 70 + case quota_model.LimitSubjectSizeAssetsArtifacts: 71 + used.Size.Assets.Artifacts = value 72 + return &used 73 + case quota_model.LimitSubjectSizeAssetsPackagesAll: 74 + used.Size.Assets.Packages.All = value 75 + return &used 76 + case quota_model.LimitSubjectSizeWiki: 77 + } 78 + 79 + return nil 80 + } 81 + 82 + func assertEvaluation(t *testing.T, rule quota_model.Rule, used quota_model.Used, subject quota_model.LimitSubject, expected bool) { 83 + t.Helper() 84 + 85 + t.Run(subject.String(), func(t *testing.T) { 86 + ok, has := rule.Evaluate(used, subject) 87 + assert.True(t, has) 88 + assert.Equal(t, expected, ok) 89 + }) 90 + } 91 + 92 + func TestQuotaRuleNoEvaluation(t *testing.T) { 93 + rule := quota_model.Rule{ 94 + Limit: 1024, 95 + Subjects: quota_model.LimitSubjects{ 96 + quota_model.LimitSubjectSizeAssetsAttachmentsAll, 97 + }, 98 + } 99 + used := quota_model.Used{} 100 + used.Size.Repos.Public = 4096 101 + 102 + _, has := rule.Evaluate(used, quota_model.LimitSubjectSizeReposAll) 103 + 104 + // We have a rule for "size:assets:attachments:all", and query for 105 + // "size:repos:all". We don't cover that subject, so the evaluation returns 106 + // with no rules found. 107 + assert.False(t, has) 108 + } 109 + 110 + func TestQuotaRuleDirectEvaluation(t *testing.T) { 111 + // This function is meant to test direct rule evaluation: cases where we set 112 + // a rule for a subject, and we evaluate against the same subject. 113 + 114 + runTest := func(t *testing.T, subject quota_model.LimitSubject, limit, used int64, expected bool) { 115 + t.Helper() 116 + 117 + rule := quota_model.Rule{ 118 + Limit: limit, 119 + Subjects: quota_model.LimitSubjects{ 120 + subject, 121 + }, 122 + } 123 + usedObj := setUsed(quota_model.Used{}, subject, used) 124 + if usedObj == nil { 125 + return 126 + } 127 + 128 + assertEvaluation(t, rule, *usedObj, subject, expected) 129 + } 130 + 131 + t.Run("limit:0", func(t *testing.T) { 132 + // With limit:0, nothing used is fine. 133 + t.Run("used:0", func(t *testing.T) { 134 + for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ { 135 + runTest(t, subject, 0, 0, true) 136 + } 137 + }) 138 + // With limit:0, any usage will fail evaluation 139 + t.Run("used:512", func(t *testing.T) { 140 + for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ { 141 + runTest(t, subject, 0, 512, false) 142 + } 143 + }) 144 + }) 145 + 146 + t.Run("limit:unlimited", func(t *testing.T) { 147 + // With no limits, any usage will succeed evaluation 148 + t.Run("used:512", func(t *testing.T) { 149 + for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ { 150 + runTest(t, subject, -1, 512, true) 151 + } 152 + }) 153 + }) 154 + 155 + t.Run("limit:1024", func(t *testing.T) { 156 + // With a set limit, usage below the limit succeeds 157 + t.Run("used:512", func(t *testing.T) { 158 + for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ { 159 + runTest(t, subject, 1024, 512, true) 160 + } 161 + }) 162 + 163 + // With a set limit, usage above the limit fails 164 + t.Run("used:2048", func(t *testing.T) { 165 + for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ { 166 + runTest(t, subject, 1024, 2048, false) 167 + } 168 + }) 169 + }) 170 + } 171 + 172 + func TestQuotaRuleCombined(t *testing.T) { 173 + rule := quota_model.Rule{ 174 + Limit: 1024, 175 + Subjects: quota_model.LimitSubjects{ 176 + quota_model.LimitSubjectSizeGitLFS, 177 + quota_model.LimitSubjectSizeAssetsAttachmentsReleases, 178 + quota_model.LimitSubjectSizeAssetsPackagesAll, 179 + }, 180 + } 181 + used := quota_model.Used{ 182 + Size: quota_model.UsedSize{ 183 + Repos: quota_model.UsedSizeRepos{ 184 + Public: 4096, 185 + }, 186 + Git: quota_model.UsedSizeGit{ 187 + LFS: 256, 188 + }, 189 + Assets: quota_model.UsedSizeAssets{ 190 + Attachments: quota_model.UsedSizeAssetsAttachments{ 191 + Issues: 2048, 192 + Releases: 256, 193 + }, 194 + Packages: quota_model.UsedSizeAssetsPackages{ 195 + All: 2560, 196 + }, 197 + }, 198 + }, 199 + } 200 + 201 + expectationMap := map[quota_model.LimitSubject]bool{ 202 + quota_model.LimitSubjectSizeGitLFS: false, 203 + quota_model.LimitSubjectSizeAssetsAttachmentsReleases: false, 204 + quota_model.LimitSubjectSizeAssetsPackagesAll: false, 205 + } 206 + 207 + for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ { 208 + t.Run(subject.String(), func(t *testing.T) { 209 + evalOk, evalHas := rule.Evaluate(used, subject) 210 + expected, expectedHas := expectationMap[subject] 211 + 212 + assert.Equal(t, expectedHas, evalHas) 213 + if expectedHas { 214 + assert.Equal(t, expected, evalOk) 215 + } 216 + }) 217 + } 218 + } 219 + 220 + func TestQuotaRuleSizeAll(t *testing.T) { 221 + runTests := func(t *testing.T, rule quota_model.Rule, expected bool) { 222 + t.Helper() 223 + 224 + subject := quota_model.LimitSubjectSizeAll 225 + 226 + t.Run("used:0", func(t *testing.T) { 227 + used := quota_model.Used{} 228 + 229 + assertEvaluation(t, rule, used, subject, true) 230 + }) 231 + 232 + t.Run("used:some-each", func(t *testing.T) { 233 + used := makeFullyUsed() 234 + 235 + assertEvaluation(t, rule, used, subject, expected) 236 + }) 237 + 238 + t.Run("used:some", func(t *testing.T) { 239 + used := makePartiallyUsed() 240 + 241 + assertEvaluation(t, rule, used, subject, expected) 242 + }) 243 + } 244 + 245 + // With all limits set to 0, evaluation always fails if usage > 0 246 + t.Run("rule:0", func(t *testing.T) { 247 + rule := quota_model.Rule{ 248 + Limit: 0, 249 + Subjects: quota_model.LimitSubjects{ 250 + quota_model.LimitSubjectSizeAll, 251 + }, 252 + } 253 + 254 + runTests(t, rule, false) 255 + }) 256 + 257 + // With no limits, evaluation always succeeds 258 + t.Run("rule:unlimited", func(t *testing.T) { 259 + rule := quota_model.Rule{ 260 + Limit: -1, 261 + Subjects: quota_model.LimitSubjects{ 262 + quota_model.LimitSubjectSizeAll, 263 + }, 264 + } 265 + 266 + runTests(t, rule, true) 267 + }) 268 + 269 + // With a specific, very generous limit, evaluation succeeds if the limit isn't exhausted 270 + t.Run("rule:generous", func(t *testing.T) { 271 + rule := quota_model.Rule{ 272 + Limit: 102400, 273 + Subjects: quota_model.LimitSubjects{ 274 + quota_model.LimitSubjectSizeAll, 275 + }, 276 + } 277 + 278 + runTests(t, rule, true) 279 + 280 + t.Run("limit exhaustion", func(t *testing.T) { 281 + used := quota_model.Used{ 282 + Size: quota_model.UsedSize{ 283 + Repos: quota_model.UsedSizeRepos{ 284 + Public: 204800, 285 + }, 286 + }, 287 + } 288 + 289 + assertEvaluation(t, rule, used, quota_model.LimitSubjectSizeAll, false) 290 + }) 291 + }) 292 + 293 + // With a specific, small limit, evaluation fails 294 + t.Run("rule:limited", func(t *testing.T) { 295 + rule := quota_model.Rule{ 296 + Limit: 512, 297 + Subjects: quota_model.LimitSubjects{ 298 + quota_model.LimitSubjectSizeAll, 299 + }, 300 + } 301 + 302 + runTests(t, rule, false) 303 + }) 304 + }
+127
models/quota/rule.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package quota 5 + 6 + import ( 7 + "context" 8 + "slices" 9 + 10 + "code.gitea.io/gitea/models/db" 11 + ) 12 + 13 + type Rule struct { 14 + Name string `xorm:"pk not null" json:"name,omitempty"` 15 + Limit int64 `xorm:"NOT NULL" binding:"Required" json:"limit"` 16 + Subjects LimitSubjects `json:"subjects,omitempty"` 17 + } 18 + 19 + func (r *Rule) TableName() string { 20 + return "quota_rule" 21 + } 22 + 23 + func (r Rule) Evaluate(used Used, forSubject LimitSubject) (bool, bool) { 24 + // If there's no limit, short circuit out 25 + if r.Limit == -1 { 26 + return true, true 27 + } 28 + 29 + // If the rule does not cover forSubject, bail out early 30 + if !slices.Contains(r.Subjects, forSubject) { 31 + return false, false 32 + } 33 + 34 + var sum int64 35 + for _, subject := range r.Subjects { 36 + sum += used.CalculateFor(subject) 37 + } 38 + return sum <= r.Limit, true 39 + } 40 + 41 + func (r *Rule) Edit(ctx context.Context, limit *int64, subjects *LimitSubjects) (*Rule, error) { 42 + cols := []string{} 43 + 44 + if limit != nil { 45 + r.Limit = *limit 46 + cols = append(cols, "limit") 47 + } 48 + if subjects != nil { 49 + r.Subjects = *subjects 50 + cols = append(cols, "subjects") 51 + } 52 + 53 + _, err := db.GetEngine(ctx).Where("name = ?", r.Name).Cols(cols...).Update(r) 54 + return r, err 55 + } 56 + 57 + func GetRuleByName(ctx context.Context, name string) (*Rule, error) { 58 + var rule Rule 59 + has, err := db.GetEngine(ctx).Where("name = ?", name).Get(&rule) 60 + if err != nil { 61 + return nil, err 62 + } 63 + if !has { 64 + return nil, nil 65 + } 66 + return &rule, err 67 + } 68 + 69 + func ListRules(ctx context.Context) ([]Rule, error) { 70 + var rules []Rule 71 + err := db.GetEngine(ctx).Find(&rules) 72 + return rules, err 73 + } 74 + 75 + func DoesRuleExist(ctx context.Context, name string) (bool, error) { 76 + return db.GetEngine(ctx). 77 + Where("name = ?", name). 78 + Get(&Rule{}) 79 + } 80 + 81 + func CreateRule(ctx context.Context, name string, limit int64, subjects LimitSubjects) (*Rule, error) { 82 + ctx, committer, err := db.TxContext(ctx) 83 + if err != nil { 84 + return nil, err 85 + } 86 + defer committer.Close() 87 + 88 + exists, err := DoesRuleExist(ctx, name) 89 + if err != nil { 90 + return nil, err 91 + } else if exists { 92 + return nil, ErrRuleAlreadyExists{Name: name} 93 + } 94 + 95 + rule := Rule{ 96 + Name: name, 97 + Limit: limit, 98 + Subjects: subjects, 99 + } 100 + _, err = db.GetEngine(ctx).Insert(rule) 101 + if err != nil { 102 + return nil, err 103 + } 104 + 105 + return &rule, committer.Commit() 106 + } 107 + 108 + func DeleteRuleByName(ctx context.Context, name string) error { 109 + ctx, committer, err := db.TxContext(ctx) 110 + if err != nil { 111 + return err 112 + } 113 + defer committer.Close() 114 + 115 + _, err = db.GetEngine(ctx).Delete(GroupRuleMapping{ 116 + RuleName: name, 117 + }) 118 + if err != nil { 119 + return err 120 + } 121 + 122 + _, err = db.GetEngine(ctx).Delete(Rule{Name: name}) 123 + if err != nil { 124 + return err 125 + } 126 + return committer.Commit() 127 + }
+252
models/quota/used.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package quota 5 + 6 + import ( 7 + "context" 8 + 9 + action_model "code.gitea.io/gitea/models/actions" 10 + "code.gitea.io/gitea/models/db" 11 + package_model "code.gitea.io/gitea/models/packages" 12 + repo_model "code.gitea.io/gitea/models/repo" 13 + 14 + "xorm.io/builder" 15 + ) 16 + 17 + type Used struct { 18 + Size UsedSize 19 + } 20 + 21 + type UsedSize struct { 22 + Repos UsedSizeRepos 23 + Git UsedSizeGit 24 + Assets UsedSizeAssets 25 + } 26 + 27 + func (u UsedSize) All() int64 { 28 + return u.Repos.All() + u.Git.All(u.Repos) + u.Assets.All() 29 + } 30 + 31 + type UsedSizeRepos struct { 32 + Public int64 33 + Private int64 34 + } 35 + 36 + func (u UsedSizeRepos) All() int64 { 37 + return u.Public + u.Private 38 + } 39 + 40 + type UsedSizeGit struct { 41 + LFS int64 42 + } 43 + 44 + func (u UsedSizeGit) All(r UsedSizeRepos) int64 { 45 + return u.LFS + r.All() 46 + } 47 + 48 + type UsedSizeAssets struct { 49 + Attachments UsedSizeAssetsAttachments 50 + Artifacts int64 51 + Packages UsedSizeAssetsPackages 52 + } 53 + 54 + func (u UsedSizeAssets) All() int64 { 55 + return u.Attachments.All() + u.Artifacts + u.Packages.All 56 + } 57 + 58 + type UsedSizeAssetsAttachments struct { 59 + Issues int64 60 + Releases int64 61 + } 62 + 63 + func (u UsedSizeAssetsAttachments) All() int64 { 64 + return u.Issues + u.Releases 65 + } 66 + 67 + type UsedSizeAssetsPackages struct { 68 + All int64 69 + } 70 + 71 + func (u Used) CalculateFor(subject LimitSubject) int64 { 72 + switch subject { 73 + case LimitSubjectNone: 74 + return 0 75 + case LimitSubjectSizeAll: 76 + return u.Size.All() 77 + case LimitSubjectSizeReposAll: 78 + return u.Size.Repos.All() 79 + case LimitSubjectSizeReposPublic: 80 + return u.Size.Repos.Public 81 + case LimitSubjectSizeReposPrivate: 82 + return u.Size.Repos.Private 83 + case LimitSubjectSizeGitAll: 84 + return u.Size.Git.All(u.Size.Repos) 85 + case LimitSubjectSizeGitLFS: 86 + return u.Size.Git.LFS 87 + case LimitSubjectSizeAssetsAll: 88 + return u.Size.Assets.All() 89 + case LimitSubjectSizeAssetsAttachmentsAll: 90 + return u.Size.Assets.Attachments.All() 91 + case LimitSubjectSizeAssetsAttachmentsIssues: 92 + return u.Size.Assets.Attachments.Issues 93 + case LimitSubjectSizeAssetsAttachmentsReleases: 94 + return u.Size.Assets.Attachments.Releases 95 + case LimitSubjectSizeAssetsArtifacts: 96 + return u.Size.Assets.Artifacts 97 + case LimitSubjectSizeAssetsPackagesAll: 98 + return u.Size.Assets.Packages.All 99 + case LimitSubjectSizeWiki: 100 + return 0 101 + } 102 + return 0 103 + } 104 + 105 + func makeUserOwnedCondition(q string, userID int64) builder.Cond { 106 + switch q { 107 + case "repositories", "attachments", "artifacts": 108 + return builder.Eq{"`repository`.owner_id": userID} 109 + case "packages": 110 + return builder.Or( 111 + builder.Eq{"`repository`.owner_id": userID}, 112 + builder.And( 113 + builder.Eq{"`package`.repo_id": 0}, 114 + builder.Eq{"`package`.owner_id": userID}, 115 + ), 116 + ) 117 + } 118 + return builder.NewCond() 119 + } 120 + 121 + func createQueryFor(ctx context.Context, userID int64, q string) db.Engine { 122 + session := db.GetEngine(ctx) 123 + 124 + switch q { 125 + case "repositories": 126 + session = session.Table("repository") 127 + case "attachments": 128 + session = session. 129 + Table("attachment"). 130 + Join("INNER", "`repository`", "`attachment`.repo_id = `repository`.id") 131 + case "artifacts": 132 + session = session. 133 + Table("action_artifact"). 134 + Join("INNER", "`repository`", "`action_artifact`.repo_id = `repository`.id") 135 + case "packages": 136 + session = session. 137 + Table("package_version"). 138 + Join("INNER", "`package_file`", "`package_file`.version_id = `package_version`.id"). 139 + Join("INNER", "`package_blob`", "`package_file`.blob_id = `package_blob`.id"). 140 + Join("INNER", "`package`", "`package_version`.package_id = `package`.id"). 141 + Join("LEFT OUTER", "`repository`", "`package`.repo_id = `repository`.id") 142 + } 143 + 144 + return session.Where(makeUserOwnedCondition(q, userID)) 145 + } 146 + 147 + func GetQuotaAttachmentsForUser(ctx context.Context, userID int64, opts db.ListOptions) (int64, *[]*repo_model.Attachment, error) { 148 + var attachments []*repo_model.Attachment 149 + 150 + sess := createQueryFor(ctx, userID, "attachments"). 151 + OrderBy("`attachment`.size DESC") 152 + if opts.PageSize > 0 { 153 + sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) 154 + } 155 + count, err := sess.FindAndCount(&attachments) 156 + if err != nil { 157 + return 0, nil, err 158 + } 159 + 160 + return count, &attachments, nil 161 + } 162 + 163 + func GetQuotaPackagesForUser(ctx context.Context, userID int64, opts db.ListOptions) (int64, *[]*package_model.PackageVersion, error) { 164 + var pkgs []*package_model.PackageVersion 165 + 166 + sess := createQueryFor(ctx, userID, "packages"). 167 + OrderBy("`package_blob`.size DESC") 168 + if opts.PageSize > 0 { 169 + sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) 170 + } 171 + count, err := sess.FindAndCount(&pkgs) 172 + if err != nil { 173 + return 0, nil, err 174 + } 175 + 176 + return count, &pkgs, nil 177 + } 178 + 179 + func GetQuotaArtifactsForUser(ctx context.Context, userID int64, opts db.ListOptions) (int64, *[]*action_model.ActionArtifact, error) { 180 + var artifacts []*action_model.ActionArtifact 181 + 182 + sess := createQueryFor(ctx, userID, "artifacts"). 183 + OrderBy("`action_artifact`.file_compressed_size DESC") 184 + if opts.PageSize > 0 { 185 + sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) 186 + } 187 + count, err := sess.FindAndCount(&artifacts) 188 + if err != nil { 189 + return 0, nil, err 190 + } 191 + 192 + return count, &artifacts, nil 193 + } 194 + 195 + func GetUsedForUser(ctx context.Context, userID int64) (*Used, error) { 196 + var used Used 197 + 198 + _, err := createQueryFor(ctx, userID, "repositories"). 199 + Where("`repository`.is_private = ?", true). 200 + Select("SUM(git_size) AS code"). 201 + Get(&used.Size.Repos.Private) 202 + if err != nil { 203 + return nil, err 204 + } 205 + 206 + _, err = createQueryFor(ctx, userID, "repositories"). 207 + Where("`repository`.is_private = ?", false). 208 + Select("SUM(git_size) AS code"). 209 + Get(&used.Size.Repos.Public) 210 + if err != nil { 211 + return nil, err 212 + } 213 + 214 + _, err = createQueryFor(ctx, userID, "repositories"). 215 + Select("SUM(lfs_size) AS lfs"). 216 + Get(&used.Size.Git.LFS) 217 + if err != nil { 218 + return nil, err 219 + } 220 + 221 + _, err = createQueryFor(ctx, userID, "attachments"). 222 + Select("SUM(`attachment`.size) AS size"). 223 + Where("`attachment`.release_id != 0"). 224 + Get(&used.Size.Assets.Attachments.Releases) 225 + if err != nil { 226 + return nil, err 227 + } 228 + 229 + _, err = createQueryFor(ctx, userID, "attachments"). 230 + Select("SUM(`attachment`.size) AS size"). 231 + Where("`attachment`.release_id = 0"). 232 + Get(&used.Size.Assets.Attachments.Issues) 233 + if err != nil { 234 + return nil, err 235 + } 236 + 237 + _, err = createQueryFor(ctx, userID, "artifacts"). 238 + Select("SUM(file_compressed_size) AS size"). 239 + Get(&used.Size.Assets.Artifacts) 240 + if err != nil { 241 + return nil, err 242 + } 243 + 244 + _, err = createQueryFor(ctx, userID, "packages"). 245 + Select("SUM(package_blob.size) AS size"). 246 + Get(&used.Size.Assets.Packages.All) 247 + if err != nil { 248 + return nil, err 249 + } 250 + 251 + return &used, nil 252 + }
+17
modules/setting/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package setting 5 + 6 + // Quota settings 7 + var Quota = struct { 8 + Enabled bool `ini:"ENABLED"` 9 + DefaultGroups []string `ini:"DEFAULT_GROUPS"` 10 + }{ 11 + Enabled: false, 12 + DefaultGroups: []string{}, 13 + } 14 + 15 + func loadQuotaFrom(rootCfg ConfigProvider) { 16 + mustMapSetting(rootCfg, "quota", &Quota) 17 + }
+1
modules/setting/setting.go
··· 155 155 loadGitFrom(cfg) 156 156 loadMirrorFrom(cfg) 157 157 loadMarkupFrom(cfg) 158 + loadQuotaFrom(cfg) 158 159 loadOtherFrom(cfg) 159 160 return nil 160 161 }
+163
modules/structs/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package structs 5 + 6 + // QuotaInfo represents information about a user's quota 7 + type QuotaInfo struct { 8 + Used QuotaUsed `json:"used"` 9 + Groups QuotaGroupList `json:"groups"` 10 + } 11 + 12 + // QuotaUsed represents the quota usage of a user 13 + type QuotaUsed struct { 14 + Size QuotaUsedSize `json:"size"` 15 + } 16 + 17 + // QuotaUsedSize represents the size-based quota usage of a user 18 + type QuotaUsedSize struct { 19 + Repos QuotaUsedSizeRepos `json:"repos"` 20 + Git QuotaUsedSizeGit `json:"git"` 21 + Assets QuotaUsedSizeAssets `json:"assets"` 22 + } 23 + 24 + // QuotaUsedSizeRepos represents the size-based repository quota usage of a user 25 + type QuotaUsedSizeRepos struct { 26 + // Storage size of the user's public repositories 27 + Public int64 `json:"public"` 28 + // Storage size of the user's private repositories 29 + Private int64 `json:"private"` 30 + } 31 + 32 + // QuotaUsedSizeGit represents the size-based git (lfs) quota usage of a user 33 + type QuotaUsedSizeGit struct { 34 + // Storage size of the user's Git LFS objects 35 + LFS int64 `json:"LFS"` 36 + } 37 + 38 + // QuotaUsedSizeAssets represents the size-based asset usage of a user 39 + type QuotaUsedSizeAssets struct { 40 + Attachments QuotaUsedSizeAssetsAttachments `json:"attachments"` 41 + // Storage size used for the user's artifacts 42 + Artifacts int64 `json:"artifacts"` 43 + Packages QuotaUsedSizeAssetsPackages `json:"packages"` 44 + } 45 + 46 + // QuotaUsedSizeAssetsAttachments represents the size-based attachment quota usage of a user 47 + type QuotaUsedSizeAssetsAttachments struct { 48 + // Storage size used for the user's issue & comment attachments 49 + Issues int64 `json:"issues"` 50 + // Storage size used for the user's release attachments 51 + Releases int64 `json:"releases"` 52 + } 53 + 54 + // QuotaUsedSizeAssetsPackages represents the size-based package quota usage of a user 55 + type QuotaUsedSizeAssetsPackages struct { 56 + // Storage suze used for the user's packages 57 + All int64 `json:"all"` 58 + } 59 + 60 + // QuotaRuleInfo contains information about a quota rule 61 + type QuotaRuleInfo struct { 62 + // Name of the rule (only shown to admins) 63 + Name string `json:"name,omitempty"` 64 + // The limit set by the rule 65 + Limit int64 `json:"limit"` 66 + // Subjects the rule affects 67 + Subjects []string `json:"subjects,omitempty"` 68 + } 69 + 70 + // QuotaGroupList represents a list of quota groups 71 + type QuotaGroupList []QuotaGroup 72 + 73 + // QuotaGroup represents a quota group 74 + type QuotaGroup struct { 75 + // Name of the group 76 + Name string `json:"name,omitempty"` 77 + // Rules associated with the group 78 + Rules []QuotaRuleInfo `json:"rules"` 79 + } 80 + 81 + // CreateQutaGroupOptions represents the options for creating a quota group 82 + type CreateQuotaGroupOptions struct { 83 + // Name of the quota group to create 84 + Name string `json:"name" binding:"Required"` 85 + // Rules to add to the newly created group. 86 + // If a rule does not exist, it will be created. 87 + Rules []CreateQuotaRuleOptions `json:"rules"` 88 + } 89 + 90 + // CreateQuotaRuleOptions represents the options for creating a quota rule 91 + type CreateQuotaRuleOptions struct { 92 + // Name of the rule to create 93 + Name string `json:"name" binding:"Required"` 94 + // The limit set by the rule 95 + Limit *int64 `json:"limit"` 96 + // The subjects affected by the rule 97 + Subjects []string `json:"subjects"` 98 + } 99 + 100 + // EditQuotaRuleOptions represents the options for editing a quota rule 101 + type EditQuotaRuleOptions struct { 102 + // The limit set by the rule 103 + Limit *int64 `json:"limit"` 104 + // The subjects affected by the rule 105 + Subjects *[]string `json:"subjects"` 106 + } 107 + 108 + // SetUserQuotaGroupsOptions represents the quota groups of a user 109 + type SetUserQuotaGroupsOptions struct { 110 + // Quota groups the user shall have 111 + // required: true 112 + Groups *[]string `json:"groups"` 113 + } 114 + 115 + // QuotaUsedAttachmentList represents a list of attachment counting towards a user's quota 116 + type QuotaUsedAttachmentList []*QuotaUsedAttachment 117 + 118 + // QuotaUsedAttachment represents an attachment counting towards a user's quota 119 + type QuotaUsedAttachment struct { 120 + // Filename of the attachment 121 + Name string `json:"name"` 122 + // Size of the attachment (in bytes) 123 + Size int64 `json:"size"` 124 + // API URL for the attachment 125 + APIURL string `json:"api_url"` 126 + // Context for the attachment: URLs to the containing object 127 + ContainedIn struct { 128 + // API URL for the object that contains this attachment 129 + APIURL string `json:"api_url"` 130 + // HTML URL for the object that contains this attachment 131 + HTMLURL string `json:"html_url"` 132 + } `json:"contained_in"` 133 + } 134 + 135 + // QuotaUsedPackageList represents a list of packages counting towards a user's quota 136 + type QuotaUsedPackageList []*QuotaUsedPackage 137 + 138 + // QuotaUsedPackage represents a package counting towards a user's quota 139 + type QuotaUsedPackage struct { 140 + // Name of the package 141 + Name string `json:"name"` 142 + // Type of the package 143 + Type string `json:"type"` 144 + // Version of the package 145 + Version string `json:"version"` 146 + // Size of the package version 147 + Size int64 `json:"size"` 148 + // HTML URL to the package version 149 + HTMLURL string `json:"html_url"` 150 + } 151 + 152 + // QuotaUsedArtifactList represents a list of artifacts counting towards a user's quota 153 + type QuotaUsedArtifactList []*QuotaUsedArtifact 154 + 155 + // QuotaUsedArtifact represents an artifact counting towards a user's quota 156 + type QuotaUsedArtifact struct { 157 + // Name of the artifact 158 + Name string `json:"name"` 159 + // Size of the artifact (compressed) 160 + Size int64 `json:"size"` 161 + // HTML URL to the action run containing the artifact 162 + HTMLURL string `json:"html_url"` 163 + }
+53
routers/api/v1/admin/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package admin 5 + 6 + import ( 7 + "net/http" 8 + 9 + quota_model "code.gitea.io/gitea/models/quota" 10 + "code.gitea.io/gitea/services/context" 11 + "code.gitea.io/gitea/services/convert" 12 + ) 13 + 14 + // GetUserQuota return information about a user's quota 15 + func GetUserQuota(ctx *context.APIContext) { 16 + // swagger:operation GET /admin/users/{username}/quota admin adminGetUserQuota 17 + // --- 18 + // summary: Get the user's quota info 19 + // produces: 20 + // - application/json 21 + // parameters: 22 + // - name: username 23 + // in: path 24 + // description: username of user to query 25 + // type: string 26 + // required: true 27 + // responses: 28 + // "200": 29 + // "$ref": "#/responses/QuotaInfo" 30 + // "400": 31 + // "$ref": "#/responses/error" 32 + // "403": 33 + // "$ref": "#/responses/forbidden" 34 + // "404": 35 + // "$ref": "#/responses/notFound" 36 + // "422": 37 + // "$ref": "#/responses/validationError" 38 + 39 + used, err := quota_model.GetUsedForUser(ctx, ctx.ContextUser.ID) 40 + if err != nil { 41 + ctx.Error(http.StatusInternalServerError, "quota_model.GetUsedForUser", err) 42 + return 43 + } 44 + 45 + groups, err := quota_model.GetGroupsForUser(ctx, ctx.ContextUser.ID) 46 + if err != nil { 47 + ctx.Error(http.StatusInternalServerError, "quota_model.GetGroupsForUser", err) 48 + return 49 + } 50 + 51 + result := convert.ToQuotaInfo(used, groups, true) 52 + ctx.JSON(http.StatusOK, &result) 53 + }
+436
routers/api/v1/admin/quota_group.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package admin 5 + 6 + import ( 7 + go_context "context" 8 + "net/http" 9 + 10 + "code.gitea.io/gitea/models/db" 11 + quota_model "code.gitea.io/gitea/models/quota" 12 + api "code.gitea.io/gitea/modules/structs" 13 + "code.gitea.io/gitea/modules/web" 14 + "code.gitea.io/gitea/services/context" 15 + "code.gitea.io/gitea/services/convert" 16 + ) 17 + 18 + // ListQuotaGroups returns all the quota groups 19 + func ListQuotaGroups(ctx *context.APIContext) { 20 + // swagger:operation GET /admin/quota/groups admin adminListQuotaGroups 21 + // --- 22 + // summary: List the available quota groups 23 + // produces: 24 + // - application/json 25 + // responses: 26 + // "200": 27 + // "$ref": "#/responses/QuotaGroupList" 28 + // "403": 29 + // "$ref": "#/responses/forbidden" 30 + 31 + groups, err := quota_model.ListGroups(ctx) 32 + if err != nil { 33 + ctx.Error(http.StatusInternalServerError, "quota_model.ListGroups", err) 34 + return 35 + } 36 + for _, group := range groups { 37 + if err = group.LoadRules(ctx); err != nil { 38 + ctx.Error(http.StatusInternalServerError, "quota_model.group.LoadRules", err) 39 + return 40 + } 41 + } 42 + 43 + ctx.JSON(http.StatusOK, convert.ToQuotaGroupList(groups, true)) 44 + } 45 + 46 + func createQuotaGroupWithRules(ctx go_context.Context, opts *api.CreateQuotaGroupOptions) (*quota_model.Group, error) { 47 + ctx, committer, err := db.TxContext(ctx) 48 + if err != nil { 49 + return nil, err 50 + } 51 + defer committer.Close() 52 + 53 + group, err := quota_model.CreateGroup(ctx, opts.Name) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + for _, rule := range opts.Rules { 59 + exists, err := quota_model.DoesRuleExist(ctx, rule.Name) 60 + if err != nil { 61 + return nil, err 62 + } 63 + if !exists { 64 + var limit int64 65 + if rule.Limit != nil { 66 + limit = *rule.Limit 67 + } 68 + 69 + subjects, err := toLimitSubjects(rule.Subjects) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + _, err = quota_model.CreateRule(ctx, rule.Name, limit, *subjects) 75 + if err != nil { 76 + return nil, err 77 + } 78 + } 79 + if err = group.AddRuleByName(ctx, rule.Name); err != nil { 80 + return nil, err 81 + } 82 + } 83 + 84 + if err = group.LoadRules(ctx); err != nil { 85 + return nil, err 86 + } 87 + 88 + return group, committer.Commit() 89 + } 90 + 91 + // CreateQuotaGroup creates a new quota group 92 + func CreateQuotaGroup(ctx *context.APIContext) { 93 + // swagger:operation POST /admin/quota/groups admin adminCreateQuotaGroup 94 + // --- 95 + // summary: Create a new quota group 96 + // produces: 97 + // - application/json 98 + // parameters: 99 + // - name: group 100 + // in: body 101 + // description: Definition of the quota group 102 + // schema: 103 + // "$ref": "#/definitions/CreateQuotaGroupOptions" 104 + // required: true 105 + // responses: 106 + // "201": 107 + // "$ref": "#/responses/QuotaGroup" 108 + // "400": 109 + // "$ref": "#/responses/error" 110 + // "403": 111 + // "$ref": "#/responses/forbidden" 112 + // "409": 113 + // "$ref": "#/responses/error" 114 + // "422": 115 + // "$ref": "#/responses/validationError" 116 + 117 + form := web.GetForm(ctx).(*api.CreateQuotaGroupOptions) 118 + 119 + group, err := createQuotaGroupWithRules(ctx, form) 120 + if err != nil { 121 + if quota_model.IsErrGroupAlreadyExists(err) { 122 + ctx.Error(http.StatusConflict, "", err) 123 + } else if quota_model.IsErrParseLimitSubjectUnrecognized(err) { 124 + ctx.Error(http.StatusUnprocessableEntity, "", err) 125 + } else { 126 + ctx.Error(http.StatusInternalServerError, "quota_model.CreateGroup", err) 127 + } 128 + return 129 + } 130 + ctx.JSON(http.StatusCreated, convert.ToQuotaGroup(*group, true)) 131 + } 132 + 133 + // ListUsersInQuotaGroup lists all the users in a quota group 134 + func ListUsersInQuotaGroup(ctx *context.APIContext) { 135 + // swagger:operation GET /admin/quota/groups/{quotagroup}/users admin adminListUsersInQuotaGroup 136 + // --- 137 + // summary: List users in a quota group 138 + // produces: 139 + // - application/json 140 + // parameters: 141 + // - name: quotagroup 142 + // in: path 143 + // description: quota group to list members of 144 + // type: string 145 + // required: true 146 + // responses: 147 + // "200": 148 + // "$ref": "#/responses/UserList" 149 + // "400": 150 + // "$ref": "#/responses/error" 151 + // "403": 152 + // "$ref": "#/responses/forbidden" 153 + // "404": 154 + // "$ref": "#/responses/notFound" 155 + 156 + users, err := quota_model.ListUsersInGroup(ctx, ctx.QuotaGroup.Name) 157 + if err != nil { 158 + ctx.Error(http.StatusInternalServerError, "quota_model.ListUsersInGroup", err) 159 + return 160 + } 161 + ctx.JSON(http.StatusOK, convert.ToUsers(ctx, ctx.Doer, users)) 162 + } 163 + 164 + // AddUserToQuotaGroup adds a user to a quota group 165 + func AddUserToQuotaGroup(ctx *context.APIContext) { 166 + // swagger:operation PUT /admin/quota/groups/{quotagroup}/users/{username} admin adminAddUserToQuotaGroup 167 + // --- 168 + // summary: Add a user to a quota group 169 + // produces: 170 + // - application/json 171 + // parameters: 172 + // - name: quotagroup 173 + // in: path 174 + // description: quota group to add the user to 175 + // type: string 176 + // required: true 177 + // - name: username 178 + // in: path 179 + // description: username of the user to add to the quota group 180 + // type: string 181 + // required: true 182 + // responses: 183 + // "204": 184 + // "$ref": "#/responses/empty" 185 + // "400": 186 + // "$ref": "#/responses/error" 187 + // "403": 188 + // "$ref": "#/responses/forbidden" 189 + // "404": 190 + // "$ref": "#/responses/notFound" 191 + // "409": 192 + // "$ref": "#/responses/error" 193 + // "422": 194 + // "$ref": "#/responses/validationError" 195 + 196 + err := ctx.QuotaGroup.AddUserByID(ctx, ctx.ContextUser.ID) 197 + if err != nil { 198 + if quota_model.IsErrUserAlreadyInGroup(err) { 199 + ctx.Error(http.StatusConflict, "", err) 200 + } else { 201 + ctx.Error(http.StatusInternalServerError, "quota_group.group.AddUserByID", err) 202 + } 203 + return 204 + } 205 + ctx.Status(http.StatusNoContent) 206 + } 207 + 208 + // RemoveUserFromQuotaGroup removes a user from a quota group 209 + func RemoveUserFromQuotaGroup(ctx *context.APIContext) { 210 + // swagger:operation DELETE /admin/quota/groups/{quotagroup}/users/{username} admin adminRemoveUserFromQuotaGroup 211 + // --- 212 + // summary: Remove a user from a quota group 213 + // produces: 214 + // - application/json 215 + // parameters: 216 + // - name: quotagroup 217 + // in: path 218 + // description: quota group to remove a user from 219 + // type: string 220 + // required: true 221 + // - name: username 222 + // in: path 223 + // description: username of the user to add to the quota group 224 + // type: string 225 + // required: true 226 + // responses: 227 + // "204": 228 + // "$ref": "#/responses/empty" 229 + // "400": 230 + // "$ref": "#/responses/error" 231 + // "403": 232 + // "$ref": "#/responses/forbidden" 233 + // "404": 234 + // "$ref": "#/responses/notFound" 235 + 236 + err := ctx.QuotaGroup.RemoveUserByID(ctx, ctx.ContextUser.ID) 237 + if err != nil { 238 + if quota_model.IsErrUserNotInGroup(err) { 239 + ctx.NotFound() 240 + } else { 241 + ctx.Error(http.StatusInternalServerError, "quota_model.group.RemoveUserByID", err) 242 + } 243 + return 244 + } 245 + ctx.Status(http.StatusNoContent) 246 + } 247 + 248 + // SetUserQuotaGroups moves the user to specific quota groups 249 + func SetUserQuotaGroups(ctx *context.APIContext) { 250 + // swagger:operation POST /admin/users/{username}/quota/groups admin adminSetUserQuotaGroups 251 + // --- 252 + // summary: Set the user's quota groups to a given list. 253 + // produces: 254 + // - application/json 255 + // parameters: 256 + // - name: username 257 + // in: path 258 + // description: username of the user to add to the quota group 259 + // type: string 260 + // required: true 261 + // - name: groups 262 + // in: body 263 + // description: quota group to remove a user from 264 + // schema: 265 + // "$ref": "#/definitions/SetUserQuotaGroupsOptions" 266 + // required: true 267 + // responses: 268 + // "204": 269 + // "$ref": "#/responses/empty" 270 + // "400": 271 + // "$ref": "#/responses/error" 272 + // "403": 273 + // "$ref": "#/responses/forbidden" 274 + // "404": 275 + // "$ref": "#/responses/notFound" 276 + // "422": 277 + // "$ref": "#/responses/validationError" 278 + 279 + form := web.GetForm(ctx).(*api.SetUserQuotaGroupsOptions) 280 + 281 + err := quota_model.SetUserGroups(ctx, ctx.ContextUser.ID, form.Groups) 282 + if err != nil { 283 + if quota_model.IsErrGroupNotFound(err) { 284 + ctx.Error(http.StatusUnprocessableEntity, "", err) 285 + } else { 286 + ctx.Error(http.StatusInternalServerError, "quota_model.SetUserGroups", err) 287 + } 288 + return 289 + } 290 + 291 + ctx.Status(http.StatusNoContent) 292 + } 293 + 294 + // DeleteQuotaGroup deletes a quota group 295 + func DeleteQuotaGroup(ctx *context.APIContext) { 296 + // swagger:operation DELETE /admin/quota/groups/{quotagroup} admin adminDeleteQuotaGroup 297 + // --- 298 + // summary: Delete a quota group 299 + // produces: 300 + // - application/json 301 + // parameters: 302 + // - name: quotagroup 303 + // in: path 304 + // description: quota group to delete 305 + // type: string 306 + // required: true 307 + // responses: 308 + // "204": 309 + // "$ref": "#/responses/empty" 310 + // "400": 311 + // "$ref": "#/responses/error" 312 + // "403": 313 + // "$ref": "#/responses/forbidden" 314 + // "404": 315 + // "$ref": "#/responses/notFound" 316 + 317 + err := quota_model.DeleteGroupByName(ctx, ctx.QuotaGroup.Name) 318 + if err != nil { 319 + ctx.Error(http.StatusInternalServerError, "quota_model.DeleteGroupByName", err) 320 + return 321 + } 322 + 323 + ctx.Status(http.StatusNoContent) 324 + } 325 + 326 + // GetQuotaGroup returns information about a quota group 327 + func GetQuotaGroup(ctx *context.APIContext) { 328 + // swagger:operation GET /admin/quota/groups/{quotagroup} admin adminGetQuotaGroup 329 + // --- 330 + // summary: Get information about the quota group 331 + // produces: 332 + // - application/json 333 + // parameters: 334 + // - name: quotagroup 335 + // in: path 336 + // description: quota group to query 337 + // type: string 338 + // required: true 339 + // responses: 340 + // "200": 341 + // "$ref": "#/responses/QuotaGroup" 342 + // "400": 343 + // "$ref": "#/responses/error" 344 + // "403": 345 + // "$ref": "#/responses/forbidden" 346 + // "404": 347 + // "$ref": "#/responses/notFound" 348 + 349 + ctx.JSON(http.StatusOK, convert.ToQuotaGroup(*ctx.QuotaGroup, true)) 350 + } 351 + 352 + // AddRuleToQuotaGroup adds a rule to a quota group 353 + func AddRuleToQuotaGroup(ctx *context.APIContext) { 354 + // swagger:operation PUT /admin/quota/groups/{quotagroup}/rules/{quotarule} admin adminAddRuleToQuotaGroup 355 + // --- 356 + // summary: Adds a rule to a quota group 357 + // produces: 358 + // - application/json 359 + // parameters: 360 + // - name: quotagroup 361 + // in: path 362 + // description: quota group to add a rule to 363 + // type: string 364 + // required: true 365 + // - name: quotarule 366 + // in: path 367 + // description: the name of the quota rule to add to the group 368 + // type: string 369 + // required: true 370 + // responses: 371 + // "204": 372 + // "$ref": "#/responses/empty" 373 + // "400": 374 + // "$ref": "#/responses/error" 375 + // "403": 376 + // "$ref": "#/responses/forbidden" 377 + // "404": 378 + // "$ref": "#/responses/notFound" 379 + // "409": 380 + // "$ref": "#/responses/error" 381 + // "422": 382 + // "$ref": "#/responses/validationError" 383 + 384 + err := ctx.QuotaGroup.AddRuleByName(ctx, ctx.QuotaRule.Name) 385 + if err != nil { 386 + if quota_model.IsErrRuleAlreadyInGroup(err) { 387 + ctx.Error(http.StatusConflict, "", err) 388 + } else if quota_model.IsErrRuleNotFound(err) { 389 + ctx.Error(http.StatusUnprocessableEntity, "", err) 390 + } else { 391 + ctx.Error(http.StatusInternalServerError, "quota_model.group.AddRuleByName", err) 392 + } 393 + return 394 + } 395 + ctx.Status(http.StatusNoContent) 396 + } 397 + 398 + // RemoveRuleFromQuotaGroup removes a rule from a quota group 399 + func RemoveRuleFromQuotaGroup(ctx *context.APIContext) { 400 + // swagger:operation DELETE /admin/quota/groups/{quotagroup}/rules/{quotarule} admin adminRemoveRuleFromQuotaGroup 401 + // --- 402 + // summary: Removes a rule from a quota group 403 + // produces: 404 + // - application/json 405 + // parameters: 406 + // - name: quotagroup 407 + // in: path 408 + // description: quota group to add a rule to 409 + // type: string 410 + // required: true 411 + // - name: quotarule 412 + // in: path 413 + // description: the name of the quota rule to remove from the group 414 + // type: string 415 + // required: true 416 + // responses: 417 + // "201": 418 + // "$ref": "#/responses/empty" 419 + // "400": 420 + // "$ref": "#/responses/error" 421 + // "403": 422 + // "$ref": "#/responses/forbidden" 423 + // "404": 424 + // "$ref": "#/responses/notFound" 425 + 426 + err := ctx.QuotaGroup.RemoveRuleByName(ctx, ctx.QuotaRule.Name) 427 + if err != nil { 428 + if quota_model.IsErrRuleNotInGroup(err) { 429 + ctx.NotFound() 430 + } else { 431 + ctx.Error(http.StatusInternalServerError, "quota_model.group.RemoveRuleByName", err) 432 + } 433 + return 434 + } 435 + ctx.Status(http.StatusNoContent) 436 + }
+219
routers/api/v1/admin/quota_rule.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package admin 5 + 6 + import ( 7 + "fmt" 8 + "net/http" 9 + 10 + quota_model "code.gitea.io/gitea/models/quota" 11 + api "code.gitea.io/gitea/modules/structs" 12 + "code.gitea.io/gitea/modules/web" 13 + "code.gitea.io/gitea/services/context" 14 + "code.gitea.io/gitea/services/convert" 15 + ) 16 + 17 + func toLimitSubjects(subjStrings []string) (*quota_model.LimitSubjects, error) { 18 + subjects := make(quota_model.LimitSubjects, len(subjStrings)) 19 + for i := range len(subjStrings) { 20 + subj, err := quota_model.ParseLimitSubject(subjStrings[i]) 21 + if err != nil { 22 + return nil, err 23 + } 24 + subjects[i] = subj 25 + } 26 + 27 + return &subjects, nil 28 + } 29 + 30 + // ListQuotaRules lists all the quota rules 31 + func ListQuotaRules(ctx *context.APIContext) { 32 + // swagger:operation GET /admin/quota/rules admin adminListQuotaRules 33 + // --- 34 + // summary: List the available quota rules 35 + // produces: 36 + // - application/json 37 + // responses: 38 + // "200": 39 + // "$ref": "#/responses/QuotaRuleInfoList" 40 + // "403": 41 + // "$ref": "#/responses/forbidden" 42 + 43 + rules, err := quota_model.ListRules(ctx) 44 + if err != nil { 45 + ctx.Error(http.StatusInternalServerError, "quota_model.ListQuotaRules", err) 46 + return 47 + } 48 + 49 + result := make([]api.QuotaRuleInfo, len(rules)) 50 + for i := range len(rules) { 51 + result[i] = convert.ToQuotaRuleInfo(rules[i], true) 52 + } 53 + 54 + ctx.JSON(http.StatusOK, result) 55 + } 56 + 57 + // CreateQuotaRule creates a new quota rule 58 + func CreateQuotaRule(ctx *context.APIContext) { 59 + // swagger:operation POST /admin/quota/rules admin adminCreateQuotaRule 60 + // --- 61 + // summary: Create a new quota rule 62 + // produces: 63 + // - application/json 64 + // parameters: 65 + // - name: rule 66 + // in: body 67 + // description: Definition of the quota rule 68 + // schema: 69 + // "$ref": "#/definitions/CreateQuotaRuleOptions" 70 + // required: true 71 + // responses: 72 + // "201": 73 + // "$ref": "#/responses/QuotaRuleInfo" 74 + // "400": 75 + // "$ref": "#/responses/error" 76 + // "403": 77 + // "$ref": "#/responses/forbidden" 78 + // "409": 79 + // "$ref": "#/responses/error" 80 + // "422": 81 + // "$ref": "#/responses/validationError" 82 + 83 + form := web.GetForm(ctx).(*api.CreateQuotaRuleOptions) 84 + 85 + if form.Limit == nil { 86 + ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", fmt.Errorf("[Limit]: Required")) 87 + return 88 + } 89 + 90 + subjects, err := toLimitSubjects(form.Subjects) 91 + if err != nil { 92 + ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", err) 93 + return 94 + } 95 + 96 + rule, err := quota_model.CreateRule(ctx, form.Name, *form.Limit, *subjects) 97 + if err != nil { 98 + if quota_model.IsErrRuleAlreadyExists(err) { 99 + ctx.Error(http.StatusConflict, "", err) 100 + } else { 101 + ctx.Error(http.StatusInternalServerError, "quota_model.CreateRule", err) 102 + } 103 + return 104 + } 105 + ctx.JSON(http.StatusCreated, convert.ToQuotaRuleInfo(*rule, true)) 106 + } 107 + 108 + // GetQuotaRule returns information about the specified quota rule 109 + func GetQuotaRule(ctx *context.APIContext) { 110 + // swagger:operation GET /admin/quota/rules/{quotarule} admin adminGetQuotaRule 111 + // --- 112 + // summary: Get information about a quota rule 113 + // produces: 114 + // - application/json 115 + // parameters: 116 + // - name: quotarule 117 + // in: path 118 + // description: quota rule to query 119 + // type: string 120 + // required: true 121 + // responses: 122 + // "200": 123 + // "$ref": "#/responses/QuotaRuleInfo" 124 + // "400": 125 + // "$ref": "#/responses/error" 126 + // "403": 127 + // "$ref": "#/responses/forbidden" 128 + // "404": 129 + // "$ref": "#/responses/notFound" 130 + 131 + ctx.JSON(http.StatusOK, convert.ToQuotaRuleInfo(*ctx.QuotaRule, true)) 132 + } 133 + 134 + // EditQuotaRule changes an existing quota rule 135 + func EditQuotaRule(ctx *context.APIContext) { 136 + // swagger:operation PATCH /admin/quota/rules/{quotarule} admin adminEditQuotaRule 137 + // --- 138 + // summary: Change an existing quota rule 139 + // produces: 140 + // - application/json 141 + // parameters: 142 + // - name: quotarule 143 + // in: path 144 + // description: Quota rule to change 145 + // type: string 146 + // required: true 147 + // - name: rule 148 + // in: body 149 + // schema: 150 + // "$ref": "#/definitions/EditQuotaRuleOptions" 151 + // required: true 152 + // responses: 153 + // "200": 154 + // "$ref": "#/responses/QuotaRuleInfo" 155 + // "400": 156 + // "$ref": "#/responses/error" 157 + // "403": 158 + // "$ref": "#/responses/forbidden" 159 + // "404": 160 + // "$ref": "#/responses/notFound" 161 + // "422": 162 + // "$ref": "#/responses/validationError" 163 + 164 + form := web.GetForm(ctx).(*api.EditQuotaRuleOptions) 165 + 166 + var subjects *quota_model.LimitSubjects 167 + if form.Subjects != nil { 168 + subjs := make(quota_model.LimitSubjects, len(*form.Subjects)) 169 + for i := range len(*form.Subjects) { 170 + subj, err := quota_model.ParseLimitSubject((*form.Subjects)[i]) 171 + if err != nil { 172 + ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", err) 173 + return 174 + } 175 + subjs[i] = subj 176 + } 177 + subjects = &subjs 178 + } 179 + 180 + rule, err := ctx.QuotaRule.Edit(ctx, form.Limit, subjects) 181 + if err != nil { 182 + ctx.Error(http.StatusInternalServerError, "quota_model.rule.Edit", err) 183 + return 184 + } 185 + 186 + ctx.JSON(http.StatusOK, convert.ToQuotaRuleInfo(*rule, true)) 187 + } 188 + 189 + // DeleteQuotaRule deletes a quota rule 190 + func DeleteQuotaRule(ctx *context.APIContext) { 191 + // swagger:operation DELETE /admin/quota/rules/{quotarule} admin adminDEleteQuotaRule 192 + // --- 193 + // summary: Deletes a quota rule 194 + // produces: 195 + // - application/json 196 + // parameters: 197 + // - name: quotarule 198 + // in: path 199 + // description: quota rule to delete 200 + // type: string 201 + // required: true 202 + // responses: 203 + // "204": 204 + // "$ref": "#/responses/empty" 205 + // "400": 206 + // "$ref": "#/responses/error" 207 + // "403": 208 + // "$ref": "#/responses/forbidden" 209 + // "404": 210 + // "$ref": "#/responses/notFound" 211 + 212 + err := quota_model.DeleteRuleByName(ctx, ctx.QuotaRule.Name) 213 + if err != nil { 214 + ctx.Error(http.StatusInternalServerError, "quota_model.DeleteRuleByName", err) 215 + return 216 + } 217 + 218 + ctx.Status(http.StatusNoContent) 219 + }
+57 -1
routers/api/v1/api.go
··· 1 1 // Copyright 2015 The Gogs Authors. All rights reserved. 2 2 // Copyright 2016 The Gitea Authors. All rights reserved. 3 - // Copyright 2023 The Forgejo Authors. All rights reserved. 3 + // Copyright 2023-2024 The Forgejo Authors. All rights reserved. 4 4 // SPDX-License-Identifier: MIT 5 5 6 6 // Package v1 Gitea API ··· 892 892 // Users (requires user scope) 893 893 m.Group("/user", func() { 894 894 m.Get("", user.GetAuthenticatedUser) 895 + if setting.Quota.Enabled { 896 + m.Group("/quota", func() { 897 + m.Get("", user.GetQuota) 898 + m.Get("/check", user.CheckQuota) 899 + m.Get("/attachments", user.ListQuotaAttachments) 900 + m.Get("/packages", user.ListQuotaPackages) 901 + m.Get("/artifacts", user.ListQuotaArtifacts) 902 + }) 903 + } 895 904 m.Group("/settings", func() { 896 905 m.Get("", user.GetUserSettings) 897 906 m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings) ··· 1482 1491 }, reqToken(), reqOrgOwnership()) 1483 1492 m.Get("/activities/feeds", org.ListOrgActivityFeeds) 1484 1493 1494 + if setting.Quota.Enabled { 1495 + m.Group("/quota", func() { 1496 + m.Get("", org.GetQuota) 1497 + m.Get("/check", org.CheckQuota) 1498 + m.Get("/attachments", org.ListQuotaAttachments) 1499 + m.Get("/packages", org.ListQuotaPackages) 1500 + m.Get("/artifacts", org.ListQuotaArtifacts) 1501 + }, reqToken(), reqOrgOwnership()) 1502 + } 1503 + 1485 1504 m.Group("", func() { 1486 1505 m.Get("/list_blocked", org.ListBlockedUsers) 1487 1506 m.Group("", func() { ··· 1531 1550 m.Post("/orgs", bind(api.CreateOrgOption{}), admin.CreateOrg) 1532 1551 m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo) 1533 1552 m.Post("/rename", bind(api.RenameUserOption{}), admin.RenameUser) 1553 + if setting.Quota.Enabled { 1554 + m.Group("/quota", func() { 1555 + m.Get("", admin.GetUserQuota) 1556 + m.Post("/groups", bind(api.SetUserQuotaGroupsOptions{}), admin.SetUserQuotaGroups) 1557 + }) 1558 + } 1534 1559 }, context.UserAssignmentAPI()) 1535 1560 }) 1536 1561 m.Group("/emails", func() { ··· 1552 1577 m.Group("/runners", func() { 1553 1578 m.Get("/registration-token", admin.GetRegistrationToken) 1554 1579 }) 1580 + if setting.Quota.Enabled { 1581 + m.Group("/quota", func() { 1582 + m.Group("/rules", func() { 1583 + m.Combo("").Get(admin.ListQuotaRules). 1584 + Post(bind(api.CreateQuotaRuleOptions{}), admin.CreateQuotaRule) 1585 + m.Combo("/{quotarule}", context.QuotaRuleAssignmentAPI()). 1586 + Get(admin.GetQuotaRule). 1587 + Patch(bind(api.EditQuotaRuleOptions{}), admin.EditQuotaRule). 1588 + Delete(admin.DeleteQuotaRule) 1589 + }) 1590 + m.Group("/groups", func() { 1591 + m.Combo("").Get(admin.ListQuotaGroups). 1592 + Post(bind(api.CreateQuotaGroupOptions{}), admin.CreateQuotaGroup) 1593 + m.Group("/{quotagroup}", func() { 1594 + m.Combo("").Get(admin.GetQuotaGroup). 1595 + Delete(admin.DeleteQuotaGroup) 1596 + m.Group("/rules", func() { 1597 + m.Combo("/{quotarule}", context.QuotaRuleAssignmentAPI()). 1598 + Put(admin.AddRuleToQuotaGroup). 1599 + Delete(admin.RemoveRuleFromQuotaGroup) 1600 + }) 1601 + m.Group("/users", func() { 1602 + m.Get("", admin.ListUsersInQuotaGroup) 1603 + m.Combo("/{username}", context.UserAssignmentAPI()). 1604 + Put(admin.AddUserToQuotaGroup). 1605 + Delete(admin.RemoveUserFromQuotaGroup) 1606 + }) 1607 + }, context.QuotaGroupAssignmentAPI()) 1608 + }) 1609 + }) 1610 + } 1555 1611 }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin()) 1556 1612 1557 1613 m.Group("/topics", func() {
+155
routers/api/v1/org/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package org 5 + 6 + import ( 7 + "code.gitea.io/gitea/routers/api/v1/shared" 8 + "code.gitea.io/gitea/services/context" 9 + ) 10 + 11 + // GetQuota returns the quota information for a given organization 12 + func GetQuota(ctx *context.APIContext) { 13 + // swagger:operation GET /orgs/{org}/quota organization orgGetQuota 14 + // --- 15 + // summary: Get quota information for an organization 16 + // produces: 17 + // - application/json 18 + // parameters: 19 + // - name: org 20 + // in: path 21 + // description: name of the organization 22 + // type: string 23 + // required: true 24 + // responses: 25 + // "200": 26 + // "$ref": "#/responses/QuotaInfo" 27 + // "403": 28 + // "$ref": "#/responses/forbidden" 29 + // "404": 30 + // "$ref": "#/responses/notFound" 31 + 32 + shared.GetQuota(ctx, ctx.Org.Organization.ID) 33 + } 34 + 35 + // CheckQuota returns whether the organization in context is over the subject quota 36 + func CheckQuota(ctx *context.APIContext) { 37 + // swagger:operation GET /orgs/{org}/quota/check organization orgCheckQuota 38 + // --- 39 + // summary: Check if the organization is over quota for a given subject 40 + // produces: 41 + // - application/json 42 + // parameters: 43 + // - name: org 44 + // in: path 45 + // description: name of the organization 46 + // type: string 47 + // required: true 48 + // responses: 49 + // "200": 50 + // "$ref": "#/responses/boolean" 51 + // "403": 52 + // "$ref": "#/responses/forbidden" 53 + // "404": 54 + // "$ref": "#/responses/notFound" 55 + // "422": 56 + // "$ref": "#/responses/validationError" 57 + 58 + shared.CheckQuota(ctx, ctx.Org.Organization.ID) 59 + } 60 + 61 + // ListQuotaAttachments lists attachments affecting the organization's quota 62 + func ListQuotaAttachments(ctx *context.APIContext) { 63 + // swagger:operation GET /orgs/{org}/quota/attachments organization orgListQuotaAttachments 64 + // --- 65 + // summary: List the attachments affecting the organization's quota 66 + // produces: 67 + // - application/json 68 + // parameters: 69 + // - name: org 70 + // in: path 71 + // description: name of the organization 72 + // type: string 73 + // required: true 74 + // - name: page 75 + // in: query 76 + // description: page number of results to return (1-based) 77 + // type: integer 78 + // - name: limit 79 + // in: query 80 + // description: page size of results 81 + // type: integer 82 + // responses: 83 + // "200": 84 + // "$ref": "#/responses/QuotaUsedAttachmentList" 85 + // "403": 86 + // "$ref": "#/responses/forbidden" 87 + // "404": 88 + // "$ref": "#/responses/notFound" 89 + 90 + shared.ListQuotaAttachments(ctx, ctx.Org.Organization.ID) 91 + } 92 + 93 + // ListQuotaPackages lists packages affecting the organization's quota 94 + func ListQuotaPackages(ctx *context.APIContext) { 95 + // swagger:operation GET /orgs/{org}/quota/packages organization orgListQuotaPackages 96 + // --- 97 + // summary: List the packages affecting the organization's quota 98 + // produces: 99 + // - application/json 100 + // parameters: 101 + // - name: org 102 + // in: path 103 + // description: name of the organization 104 + // type: string 105 + // required: true 106 + // - name: page 107 + // in: query 108 + // description: page number of results to return (1-based) 109 + // type: integer 110 + // - name: limit 111 + // in: query 112 + // description: page size of results 113 + // type: integer 114 + // responses: 115 + // "200": 116 + // "$ref": "#/responses/QuotaUsedPackageList" 117 + // "403": 118 + // "$ref": "#/responses/forbidden" 119 + // "404": 120 + // "$ref": "#/responses/notFound" 121 + 122 + shared.ListQuotaPackages(ctx, ctx.Org.Organization.ID) 123 + } 124 + 125 + // ListQuotaArtifacts lists artifacts affecting the organization's quota 126 + func ListQuotaArtifacts(ctx *context.APIContext) { 127 + // swagger:operation GET /orgs/{org}/quota/artifacts organization orgListQuotaArtifacts 128 + // --- 129 + // summary: List the artifacts affecting the organization's quota 130 + // produces: 131 + // - application/json 132 + // parameters: 133 + // - name: org 134 + // in: path 135 + // description: name of the organization 136 + // type: string 137 + // required: true 138 + // - name: page 139 + // in: query 140 + // description: page number of results to return (1-based) 141 + // type: integer 142 + // - name: limit 143 + // in: query 144 + // description: page size of results 145 + // type: integer 146 + // responses: 147 + // "200": 148 + // "$ref": "#/responses/QuotaUsedArtifactList" 149 + // "403": 150 + // "$ref": "#/responses/forbidden" 151 + // "404": 152 + // "$ref": "#/responses/notFound" 153 + 154 + shared.ListQuotaArtifacts(ctx, ctx.Org.Organization.ID) 155 + }
+102
routers/api/v1/shared/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package shared 5 + 6 + import ( 7 + "net/http" 8 + 9 + quota_model "code.gitea.io/gitea/models/quota" 10 + "code.gitea.io/gitea/routers/api/v1/utils" 11 + "code.gitea.io/gitea/services/context" 12 + "code.gitea.io/gitea/services/convert" 13 + ) 14 + 15 + func GetQuota(ctx *context.APIContext, userID int64) { 16 + used, err := quota_model.GetUsedForUser(ctx, userID) 17 + if err != nil { 18 + ctx.Error(http.StatusInternalServerError, "quota_model.GetUsedForUser", err) 19 + return 20 + } 21 + 22 + groups, err := quota_model.GetGroupsForUser(ctx, userID) 23 + if err != nil { 24 + ctx.Error(http.StatusInternalServerError, "quota_model.GetGroupsForUser", err) 25 + return 26 + } 27 + 28 + result := convert.ToQuotaInfo(used, groups, false) 29 + ctx.JSON(http.StatusOK, &result) 30 + } 31 + 32 + func CheckQuota(ctx *context.APIContext, userID int64) { 33 + subjectQuery := ctx.FormTrim("subject") 34 + 35 + subject, err := quota_model.ParseLimitSubject(subjectQuery) 36 + if err != nil { 37 + ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", err) 38 + return 39 + } 40 + 41 + ok, err := quota_model.EvaluateForUser(ctx, userID, subject) 42 + if err != nil { 43 + ctx.Error(http.StatusInternalServerError, "quota_model.EvaluateForUser", err) 44 + return 45 + } 46 + 47 + ctx.JSON(http.StatusOK, &ok) 48 + } 49 + 50 + func ListQuotaAttachments(ctx *context.APIContext, userID int64) { 51 + opts := utils.GetListOptions(ctx) 52 + count, attachments, err := quota_model.GetQuotaAttachmentsForUser(ctx, userID, opts) 53 + if err != nil { 54 + ctx.Error(http.StatusInternalServerError, "GetQuotaAttachmentsForUser", err) 55 + return 56 + } 57 + 58 + result, err := convert.ToQuotaUsedAttachmentList(ctx, *attachments) 59 + if err != nil { 60 + ctx.Error(http.StatusInternalServerError, "convert.ToQuotaUsedAttachmentList", err) 61 + } 62 + 63 + ctx.SetLinkHeader(int(count), opts.PageSize) 64 + ctx.SetTotalCountHeader(count) 65 + ctx.JSON(http.StatusOK, result) 66 + } 67 + 68 + func ListQuotaPackages(ctx *context.APIContext, userID int64) { 69 + opts := utils.GetListOptions(ctx) 70 + count, packages, err := quota_model.GetQuotaPackagesForUser(ctx, userID, opts) 71 + if err != nil { 72 + ctx.Error(http.StatusInternalServerError, "GetQuotaPackagesForUser", err) 73 + return 74 + } 75 + 76 + result, err := convert.ToQuotaUsedPackageList(ctx, *packages) 77 + if err != nil { 78 + ctx.Error(http.StatusInternalServerError, "convert.ToQuotaUsedPackageList", err) 79 + } 80 + 81 + ctx.SetLinkHeader(int(count), opts.PageSize) 82 + ctx.SetTotalCountHeader(count) 83 + ctx.JSON(http.StatusOK, result) 84 + } 85 + 86 + func ListQuotaArtifacts(ctx *context.APIContext, userID int64) { 87 + opts := utils.GetListOptions(ctx) 88 + count, artifacts, err := quota_model.GetQuotaArtifactsForUser(ctx, userID, opts) 89 + if err != nil { 90 + ctx.Error(http.StatusInternalServerError, "GetQuotaArtifactsForUser", err) 91 + return 92 + } 93 + 94 + result, err := convert.ToQuotaUsedArtifactList(ctx, *artifacts) 95 + if err != nil { 96 + ctx.Error(http.StatusInternalServerError, "convert.ToQuotaUsedArtifactList", err) 97 + } 98 + 99 + ctx.SetLinkHeader(int(count), opts.PageSize) 100 + ctx.SetTotalCountHeader(count) 101 + ctx.JSON(http.StatusOK, result) 102 + }
+7
routers/api/v1/swagger/misc.go
··· 62 62 // in:body 63 63 Body []api.LabelTemplate `json:"body"` 64 64 } 65 + 66 + // Boolean 67 + // swagger:response boolean 68 + type swaggerResponseBoolean struct { 69 + // in:body 70 + Body bool `json:"body"` 71 + }
+12
routers/api/v1/swagger/options.go
··· 219 219 220 220 // in:body 221 221 DispatchWorkflowOption api.DispatchWorkflowOption 222 + 223 + // in:body 224 + CreateQuotaGroupOptions api.CreateQuotaGroupOptions 225 + 226 + // in:body 227 + CreateQuotaRuleOptions api.CreateQuotaRuleOptions 228 + 229 + // in:body 230 + EditQuotaRuleOptions api.EditQuotaRuleOptions 231 + 232 + // in:body 233 + SetUserQuotaGroupsOptions api.SetUserQuotaGroupsOptions 222 234 }
+64
routers/api/v1/swagger/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package swagger 5 + 6 + import ( 7 + api "code.gitea.io/gitea/modules/structs" 8 + ) 9 + 10 + // QuotaInfo 11 + // swagger:response QuotaInfo 12 + type swaggerResponseQuotaInfo struct { 13 + // in:body 14 + Body api.QuotaInfo `json:"body"` 15 + } 16 + 17 + // QuotaRuleInfoList 18 + // swagger:response QuotaRuleInfoList 19 + type swaggerResponseQuotaRuleInfoList struct { 20 + // in:body 21 + Body []api.QuotaRuleInfo `json:"body"` 22 + } 23 + 24 + // QuotaRuleInfo 25 + // swagger:response QuotaRuleInfo 26 + type swaggerResponseQuotaRuleInfo struct { 27 + // in:body 28 + Body api.QuotaRuleInfo `json:"body"` 29 + } 30 + 31 + // QuotaUsedAttachmentList 32 + // swagger:response QuotaUsedAttachmentList 33 + type swaggerQuotaUsedAttachmentList struct { 34 + // in:body 35 + Body api.QuotaUsedAttachmentList `json:"body"` 36 + } 37 + 38 + // QuotaUsedPackageList 39 + // swagger:response QuotaUsedPackageList 40 + type swaggerQuotaUsedPackageList struct { 41 + // in:body 42 + Body api.QuotaUsedPackageList `json:"body"` 43 + } 44 + 45 + // QuotaUsedArtifactList 46 + // swagger:response QuotaUsedArtifactList 47 + type swaggerQuotaUsedArtifactList struct { 48 + // in:body 49 + Body api.QuotaUsedArtifactList `json:"body"` 50 + } 51 + 52 + // QuotaGroup 53 + // swagger:response QuotaGroup 54 + type swaggerResponseQuotaGroup struct { 55 + // in:body 56 + Body api.QuotaGroup `json:"body"` 57 + } 58 + 59 + // QuotaGroupList 60 + // swagger:response QuotaGroupList 61 + type swaggerResponseQuotaGroupList struct { 62 + // in:body 63 + Body api.QuotaGroupList `json:"body"` 64 + }
+118
routers/api/v1/user/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package user 5 + 6 + import ( 7 + "code.gitea.io/gitea/routers/api/v1/shared" 8 + "code.gitea.io/gitea/services/context" 9 + ) 10 + 11 + // GetQuota returns the quota information for the authenticated user 12 + func GetQuota(ctx *context.APIContext) { 13 + // swagger:operation GET /user/quota user userGetQuota 14 + // --- 15 + // summary: Get quota information for the authenticated user 16 + // produces: 17 + // - application/json 18 + // responses: 19 + // "200": 20 + // "$ref": "#/responses/QuotaInfo" 21 + // "403": 22 + // "$ref": "#/responses/forbidden" 23 + 24 + shared.GetQuota(ctx, ctx.Doer.ID) 25 + } 26 + 27 + // CheckQuota returns whether the authenticated user is over the subject quota 28 + func CheckQuota(ctx *context.APIContext) { 29 + // swagger:operation GET /user/quota/check user userCheckQuota 30 + // --- 31 + // summary: Check if the authenticated user is over quota for a given subject 32 + // produces: 33 + // - application/json 34 + // responses: 35 + // "200": 36 + // "$ref": "#/responses/boolean" 37 + // "403": 38 + // "$ref": "#/responses/forbidden" 39 + // "422": 40 + // "$ref": "#/responses/validationError" 41 + 42 + shared.CheckQuota(ctx, ctx.Doer.ID) 43 + } 44 + 45 + // ListQuotaAttachments lists attachments affecting the authenticated user's quota 46 + func ListQuotaAttachments(ctx *context.APIContext) { 47 + // swagger:operation GET /user/quota/attachments user userListQuotaAttachments 48 + // --- 49 + // summary: List the attachments affecting the authenticated user's quota 50 + // produces: 51 + // - application/json 52 + // parameters: 53 + // - name: page 54 + // in: query 55 + // description: page number of results to return (1-based) 56 + // type: integer 57 + // - name: limit 58 + // in: query 59 + // description: page size of results 60 + // type: integer 61 + // responses: 62 + // "200": 63 + // "$ref": "#/responses/QuotaUsedAttachmentList" 64 + // "403": 65 + // "$ref": "#/responses/forbidden" 66 + 67 + shared.ListQuotaAttachments(ctx, ctx.Doer.ID) 68 + } 69 + 70 + // ListQuotaPackages lists packages affecting the authenticated user's quota 71 + func ListQuotaPackages(ctx *context.APIContext) { 72 + // swagger:operation GET /user/quota/packages user userListQuotaPackages 73 + // --- 74 + // summary: List the packages affecting the authenticated user's quota 75 + // produces: 76 + // - application/json 77 + // parameters: 78 + // - name: page 79 + // in: query 80 + // description: page number of results to return (1-based) 81 + // type: integer 82 + // - name: limit 83 + // in: query 84 + // description: page size of results 85 + // type: integer 86 + // responses: 87 + // "200": 88 + // "$ref": "#/responses/QuotaUsedPackageList" 89 + // "403": 90 + // "$ref": "#/responses/forbidden" 91 + 92 + shared.ListQuotaPackages(ctx, ctx.Doer.ID) 93 + } 94 + 95 + // ListQuotaArtifacts lists artifacts affecting the authenticated user's quota 96 + func ListQuotaArtifacts(ctx *context.APIContext) { 97 + // swagger:operation GET /user/quota/artifacts user userListQuotaArtifacts 98 + // --- 99 + // summary: List the artifacts affecting the authenticated user's quota 100 + // produces: 101 + // - application/json 102 + // parameters: 103 + // - name: page 104 + // in: query 105 + // description: page number of results to return (1-based) 106 + // type: integer 107 + // - name: limit 108 + // in: query 109 + // description: page size of results 110 + // type: integer 111 + // responses: 112 + // "200": 113 + // "$ref": "#/responses/QuotaUsedArtifactList" 114 + // "403": 115 + // "$ref": "#/responses/forbidden" 116 + 117 + shared.ListQuotaArtifacts(ctx, ctx.Doer.ID) 118 + }
+7 -4
services/context/api.go
··· 12 12 "strings" 13 13 14 14 issues_model "code.gitea.io/gitea/models/issues" 15 + quota_model "code.gitea.io/gitea/models/quota" 15 16 "code.gitea.io/gitea/models/unit" 16 17 user_model "code.gitea.io/gitea/models/user" 17 18 mc "code.gitea.io/gitea/modules/cache" ··· 38 39 39 40 ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer 40 41 41 - Repo *Repository 42 - Comment *issues_model.Comment 43 - Org *APIOrganization 44 - Package *Package 42 + Repo *Repository 43 + Comment *issues_model.Comment 44 + Org *APIOrganization 45 + Package *Package 46 + QuotaGroup *quota_model.Group 47 + QuotaRule *quota_model.Rule 45 48 } 46 49 47 50 func init() {
+44
services/context/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package context 5 + 6 + import ( 7 + "net/http" 8 + 9 + quota_model "code.gitea.io/gitea/models/quota" 10 + ) 11 + 12 + // QuotaGroupAssignmentAPI returns a middleware to handle context-quota-group assignment for api routes 13 + func QuotaGroupAssignmentAPI() func(ctx *APIContext) { 14 + return func(ctx *APIContext) { 15 + groupName := ctx.Params("quotagroup") 16 + group, err := quota_model.GetGroupByName(ctx, groupName) 17 + if err != nil { 18 + ctx.Error(http.StatusInternalServerError, "quota_model.GetGroupByName", err) 19 + return 20 + } 21 + if group == nil { 22 + ctx.NotFound() 23 + return 24 + } 25 + ctx.QuotaGroup = group 26 + } 27 + } 28 + 29 + // QuotaRuleAssignmentAPI returns a middleware to handle context-quota-rule assignment for api routes 30 + func QuotaRuleAssignmentAPI() func(ctx *APIContext) { 31 + return func(ctx *APIContext) { 32 + ruleName := ctx.Params("quotarule") 33 + rule, err := quota_model.GetRuleByName(ctx, ruleName) 34 + if err != nil { 35 + ctx.Error(http.StatusInternalServerError, "quota_model.GetRuleByName", err) 36 + return 37 + } 38 + if rule == nil { 39 + ctx.NotFound() 40 + return 41 + } 42 + ctx.QuotaRule = rule 43 + } 44 + }
+185
services/convert/quota.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package convert 5 + 6 + import ( 7 + "context" 8 + "strconv" 9 + 10 + action_model "code.gitea.io/gitea/models/actions" 11 + issue_model "code.gitea.io/gitea/models/issues" 12 + package_model "code.gitea.io/gitea/models/packages" 13 + quota_model "code.gitea.io/gitea/models/quota" 14 + repo_model "code.gitea.io/gitea/models/repo" 15 + api "code.gitea.io/gitea/modules/structs" 16 + ) 17 + 18 + func ToQuotaRuleInfo(rule quota_model.Rule, withName bool) api.QuotaRuleInfo { 19 + info := api.QuotaRuleInfo{ 20 + Limit: rule.Limit, 21 + Subjects: make([]string, len(rule.Subjects)), 22 + } 23 + for i := range len(rule.Subjects) { 24 + info.Subjects[i] = rule.Subjects[i].String() 25 + } 26 + 27 + if withName { 28 + info.Name = rule.Name 29 + } 30 + 31 + return info 32 + } 33 + 34 + func toQuotaInfoUsed(used *quota_model.Used) api.QuotaUsed { 35 + info := api.QuotaUsed{ 36 + Size: api.QuotaUsedSize{ 37 + Repos: api.QuotaUsedSizeRepos{ 38 + Public: used.Size.Repos.Public, 39 + Private: used.Size.Repos.Private, 40 + }, 41 + Git: api.QuotaUsedSizeGit{ 42 + LFS: used.Size.Git.LFS, 43 + }, 44 + Assets: api.QuotaUsedSizeAssets{ 45 + Attachments: api.QuotaUsedSizeAssetsAttachments{ 46 + Issues: used.Size.Assets.Attachments.Issues, 47 + Releases: used.Size.Assets.Attachments.Releases, 48 + }, 49 + Artifacts: used.Size.Assets.Artifacts, 50 + Packages: api.QuotaUsedSizeAssetsPackages{ 51 + All: used.Size.Assets.Packages.All, 52 + }, 53 + }, 54 + }, 55 + } 56 + return info 57 + } 58 + 59 + func ToQuotaInfo(used *quota_model.Used, groups quota_model.GroupList, withNames bool) api.QuotaInfo { 60 + info := api.QuotaInfo{ 61 + Used: toQuotaInfoUsed(used), 62 + Groups: ToQuotaGroupList(groups, withNames), 63 + } 64 + 65 + return info 66 + } 67 + 68 + func ToQuotaGroup(group quota_model.Group, withNames bool) api.QuotaGroup { 69 + info := api.QuotaGroup{ 70 + Rules: make([]api.QuotaRuleInfo, len(group.Rules)), 71 + } 72 + if withNames { 73 + info.Name = group.Name 74 + } 75 + for i := range len(group.Rules) { 76 + info.Rules[i] = ToQuotaRuleInfo(group.Rules[i], withNames) 77 + } 78 + 79 + return info 80 + } 81 + 82 + func ToQuotaGroupList(groups quota_model.GroupList, withNames bool) api.QuotaGroupList { 83 + list := make(api.QuotaGroupList, len(groups)) 84 + 85 + for i := range len(groups) { 86 + list[i] = ToQuotaGroup(*groups[i], withNames) 87 + } 88 + 89 + return list 90 + } 91 + 92 + func ToQuotaUsedAttachmentList(ctx context.Context, attachments []*repo_model.Attachment) (*api.QuotaUsedAttachmentList, error) { 93 + getAttachmentContainer := func(a *repo_model.Attachment) (string, string, error) { 94 + if a.ReleaseID != 0 { 95 + release, err := repo_model.GetReleaseByID(ctx, a.ReleaseID) 96 + if err != nil { 97 + return "", "", err 98 + } 99 + if err = release.LoadAttributes(ctx); err != nil { 100 + return "", "", err 101 + } 102 + return release.APIURL(), release.HTMLURL(), nil 103 + } 104 + if a.CommentID != 0 { 105 + comment, err := issue_model.GetCommentByID(ctx, a.CommentID) 106 + if err != nil { 107 + return "", "", err 108 + } 109 + return comment.APIURL(ctx), comment.HTMLURL(ctx), nil 110 + } 111 + if a.IssueID != 0 { 112 + issue, err := issue_model.GetIssueByID(ctx, a.IssueID) 113 + if err != nil { 114 + return "", "", err 115 + } 116 + if err = issue.LoadRepo(ctx); err != nil { 117 + return "", "", err 118 + } 119 + return issue.APIURL(ctx), issue.HTMLURL(), nil 120 + } 121 + return "", "", nil 122 + } 123 + 124 + result := make(api.QuotaUsedAttachmentList, len(attachments)) 125 + for i, a := range attachments { 126 + capiURL, chtmlURL, err := getAttachmentContainer(a) 127 + if err != nil { 128 + return nil, err 129 + } 130 + 131 + apiURL := capiURL + "/assets/" + strconv.FormatInt(a.ID, 10) 132 + result[i] = &api.QuotaUsedAttachment{ 133 + Name: a.Name, 134 + Size: a.Size, 135 + APIURL: apiURL, 136 + } 137 + result[i].ContainedIn.APIURL = capiURL 138 + result[i].ContainedIn.HTMLURL = chtmlURL 139 + } 140 + 141 + return &result, nil 142 + } 143 + 144 + func ToQuotaUsedPackageList(ctx context.Context, packages []*package_model.PackageVersion) (*api.QuotaUsedPackageList, error) { 145 + result := make(api.QuotaUsedPackageList, len(packages)) 146 + for i, pv := range packages { 147 + d, err := package_model.GetPackageDescriptor(ctx, pv) 148 + if err != nil { 149 + return nil, err 150 + } 151 + 152 + var size int64 153 + for _, file := range d.Files { 154 + size += file.Blob.Size 155 + } 156 + 157 + result[i] = &api.QuotaUsedPackage{ 158 + Name: d.Package.Name, 159 + Type: d.Package.Type.Name(), 160 + Version: d.Version.Version, 161 + Size: size, 162 + HTMLURL: d.VersionHTMLURL(), 163 + } 164 + } 165 + 166 + return &result, nil 167 + } 168 + 169 + func ToQuotaUsedArtifactList(ctx context.Context, artifacts []*action_model.ActionArtifact) (*api.QuotaUsedArtifactList, error) { 170 + result := make(api.QuotaUsedArtifactList, len(artifacts)) 171 + for i, a := range artifacts { 172 + run, err := action_model.GetRunByID(ctx, a.RunID) 173 + if err != nil { 174 + return nil, err 175 + } 176 + 177 + result[i] = &api.QuotaUsedArtifact{ 178 + Name: a.ArtifactName, 179 + Size: a.FileCompressedSize, 180 + HTMLURL: run.HTMLURL(), 181 + } 182 + } 183 + 184 + return &result, nil 185 + }
+1371 -1
templates/swagger/v1_json.tmpl
··· 451 451 } 452 452 } 453 453 }, 454 + "/admin/quota/groups": { 455 + "get": { 456 + "produces": [ 457 + "application/json" 458 + ], 459 + "tags": [ 460 + "admin" 461 + ], 462 + "summary": "List the available quota groups", 463 + "operationId": "adminListQuotaGroups", 464 + "responses": { 465 + "200": { 466 + "$ref": "#/responses/QuotaGroupList" 467 + }, 468 + "403": { 469 + "$ref": "#/responses/forbidden" 470 + } 471 + } 472 + }, 473 + "post": { 474 + "produces": [ 475 + "application/json" 476 + ], 477 + "tags": [ 478 + "admin" 479 + ], 480 + "summary": "Create a new quota group", 481 + "operationId": "adminCreateQuotaGroup", 482 + "parameters": [ 483 + { 484 + "description": "Definition of the quota group", 485 + "name": "group", 486 + "in": "body", 487 + "required": true, 488 + "schema": { 489 + "$ref": "#/definitions/CreateQuotaGroupOptions" 490 + } 491 + } 492 + ], 493 + "responses": { 494 + "201": { 495 + "$ref": "#/responses/QuotaGroup" 496 + }, 497 + "400": { 498 + "$ref": "#/responses/error" 499 + }, 500 + "403": { 501 + "$ref": "#/responses/forbidden" 502 + }, 503 + "409": { 504 + "$ref": "#/responses/error" 505 + }, 506 + "422": { 507 + "$ref": "#/responses/validationError" 508 + } 509 + } 510 + } 511 + }, 512 + "/admin/quota/groups/{quotagroup}": { 513 + "get": { 514 + "produces": [ 515 + "application/json" 516 + ], 517 + "tags": [ 518 + "admin" 519 + ], 520 + "summary": "Get information about the quota group", 521 + "operationId": "adminGetQuotaGroup", 522 + "parameters": [ 523 + { 524 + "type": "string", 525 + "description": "quota group to query", 526 + "name": "quotagroup", 527 + "in": "path", 528 + "required": true 529 + } 530 + ], 531 + "responses": { 532 + "200": { 533 + "$ref": "#/responses/QuotaGroup" 534 + }, 535 + "400": { 536 + "$ref": "#/responses/error" 537 + }, 538 + "403": { 539 + "$ref": "#/responses/forbidden" 540 + }, 541 + "404": { 542 + "$ref": "#/responses/notFound" 543 + } 544 + } 545 + }, 546 + "delete": { 547 + "produces": [ 548 + "application/json" 549 + ], 550 + "tags": [ 551 + "admin" 552 + ], 553 + "summary": "Delete a quota group", 554 + "operationId": "adminDeleteQuotaGroup", 555 + "parameters": [ 556 + { 557 + "type": "string", 558 + "description": "quota group to delete", 559 + "name": "quotagroup", 560 + "in": "path", 561 + "required": true 562 + } 563 + ], 564 + "responses": { 565 + "204": { 566 + "$ref": "#/responses/empty" 567 + }, 568 + "400": { 569 + "$ref": "#/responses/error" 570 + }, 571 + "403": { 572 + "$ref": "#/responses/forbidden" 573 + }, 574 + "404": { 575 + "$ref": "#/responses/notFound" 576 + } 577 + } 578 + } 579 + }, 580 + "/admin/quota/groups/{quotagroup}/rules/{quotarule}": { 581 + "put": { 582 + "produces": [ 583 + "application/json" 584 + ], 585 + "tags": [ 586 + "admin" 587 + ], 588 + "summary": "Adds a rule to a quota group", 589 + "operationId": "adminAddRuleToQuotaGroup", 590 + "parameters": [ 591 + { 592 + "type": "string", 593 + "description": "quota group to add a rule to", 594 + "name": "quotagroup", 595 + "in": "path", 596 + "required": true 597 + }, 598 + { 599 + "type": "string", 600 + "description": "the name of the quota rule to add to the group", 601 + "name": "quotarule", 602 + "in": "path", 603 + "required": true 604 + } 605 + ], 606 + "responses": { 607 + "204": { 608 + "$ref": "#/responses/empty" 609 + }, 610 + "400": { 611 + "$ref": "#/responses/error" 612 + }, 613 + "403": { 614 + "$ref": "#/responses/forbidden" 615 + }, 616 + "404": { 617 + "$ref": "#/responses/notFound" 618 + }, 619 + "409": { 620 + "$ref": "#/responses/error" 621 + }, 622 + "422": { 623 + "$ref": "#/responses/validationError" 624 + } 625 + } 626 + }, 627 + "delete": { 628 + "produces": [ 629 + "application/json" 630 + ], 631 + "tags": [ 632 + "admin" 633 + ], 634 + "summary": "Removes a rule from a quota group", 635 + "operationId": "adminRemoveRuleFromQuotaGroup", 636 + "parameters": [ 637 + { 638 + "type": "string", 639 + "description": "quota group to add a rule to", 640 + "name": "quotagroup", 641 + "in": "path", 642 + "required": true 643 + }, 644 + { 645 + "type": "string", 646 + "description": "the name of the quota rule to remove from the group", 647 + "name": "quotarule", 648 + "in": "path", 649 + "required": true 650 + } 651 + ], 652 + "responses": { 653 + "201": { 654 + "$ref": "#/responses/empty" 655 + }, 656 + "400": { 657 + "$ref": "#/responses/error" 658 + }, 659 + "403": { 660 + "$ref": "#/responses/forbidden" 661 + }, 662 + "404": { 663 + "$ref": "#/responses/notFound" 664 + } 665 + } 666 + } 667 + }, 668 + "/admin/quota/groups/{quotagroup}/users": { 669 + "get": { 670 + "produces": [ 671 + "application/json" 672 + ], 673 + "tags": [ 674 + "admin" 675 + ], 676 + "summary": "List users in a quota group", 677 + "operationId": "adminListUsersInQuotaGroup", 678 + "parameters": [ 679 + { 680 + "type": "string", 681 + "description": "quota group to list members of", 682 + "name": "quotagroup", 683 + "in": "path", 684 + "required": true 685 + } 686 + ], 687 + "responses": { 688 + "200": { 689 + "$ref": "#/responses/UserList" 690 + }, 691 + "400": { 692 + "$ref": "#/responses/error" 693 + }, 694 + "403": { 695 + "$ref": "#/responses/forbidden" 696 + }, 697 + "404": { 698 + "$ref": "#/responses/notFound" 699 + } 700 + } 701 + } 702 + }, 703 + "/admin/quota/groups/{quotagroup}/users/{username}": { 704 + "put": { 705 + "produces": [ 706 + "application/json" 707 + ], 708 + "tags": [ 709 + "admin" 710 + ], 711 + "summary": "Add a user to a quota group", 712 + "operationId": "adminAddUserToQuotaGroup", 713 + "parameters": [ 714 + { 715 + "type": "string", 716 + "description": "quota group to add the user to", 717 + "name": "quotagroup", 718 + "in": "path", 719 + "required": true 720 + }, 721 + { 722 + "type": "string", 723 + "description": "username of the user to add to the quota group", 724 + "name": "username", 725 + "in": "path", 726 + "required": true 727 + } 728 + ], 729 + "responses": { 730 + "204": { 731 + "$ref": "#/responses/empty" 732 + }, 733 + "400": { 734 + "$ref": "#/responses/error" 735 + }, 736 + "403": { 737 + "$ref": "#/responses/forbidden" 738 + }, 739 + "404": { 740 + "$ref": "#/responses/notFound" 741 + }, 742 + "409": { 743 + "$ref": "#/responses/error" 744 + }, 745 + "422": { 746 + "$ref": "#/responses/validationError" 747 + } 748 + } 749 + }, 750 + "delete": { 751 + "produces": [ 752 + "application/json" 753 + ], 754 + "tags": [ 755 + "admin" 756 + ], 757 + "summary": "Remove a user from a quota group", 758 + "operationId": "adminRemoveUserFromQuotaGroup", 759 + "parameters": [ 760 + { 761 + "type": "string", 762 + "description": "quota group to remove a user from", 763 + "name": "quotagroup", 764 + "in": "path", 765 + "required": true 766 + }, 767 + { 768 + "type": "string", 769 + "description": "username of the user to add to the quota group", 770 + "name": "username", 771 + "in": "path", 772 + "required": true 773 + } 774 + ], 775 + "responses": { 776 + "204": { 777 + "$ref": "#/responses/empty" 778 + }, 779 + "400": { 780 + "$ref": "#/responses/error" 781 + }, 782 + "403": { 783 + "$ref": "#/responses/forbidden" 784 + }, 785 + "404": { 786 + "$ref": "#/responses/notFound" 787 + } 788 + } 789 + } 790 + }, 791 + "/admin/quota/rules": { 792 + "get": { 793 + "produces": [ 794 + "application/json" 795 + ], 796 + "tags": [ 797 + "admin" 798 + ], 799 + "summary": "List the available quota rules", 800 + "operationId": "adminListQuotaRules", 801 + "responses": { 802 + "200": { 803 + "$ref": "#/responses/QuotaRuleInfoList" 804 + }, 805 + "403": { 806 + "$ref": "#/responses/forbidden" 807 + } 808 + } 809 + }, 810 + "post": { 811 + "produces": [ 812 + "application/json" 813 + ], 814 + "tags": [ 815 + "admin" 816 + ], 817 + "summary": "Create a new quota rule", 818 + "operationId": "adminCreateQuotaRule", 819 + "parameters": [ 820 + { 821 + "description": "Definition of the quota rule", 822 + "name": "rule", 823 + "in": "body", 824 + "required": true, 825 + "schema": { 826 + "$ref": "#/definitions/CreateQuotaRuleOptions" 827 + } 828 + } 829 + ], 830 + "responses": { 831 + "201": { 832 + "$ref": "#/responses/QuotaRuleInfo" 833 + }, 834 + "400": { 835 + "$ref": "#/responses/error" 836 + }, 837 + "403": { 838 + "$ref": "#/responses/forbidden" 839 + }, 840 + "409": { 841 + "$ref": "#/responses/error" 842 + }, 843 + "422": { 844 + "$ref": "#/responses/validationError" 845 + } 846 + } 847 + } 848 + }, 849 + "/admin/quota/rules/{quotarule}": { 850 + "get": { 851 + "produces": [ 852 + "application/json" 853 + ], 854 + "tags": [ 855 + "admin" 856 + ], 857 + "summary": "Get information about a quota rule", 858 + "operationId": "adminGetQuotaRule", 859 + "parameters": [ 860 + { 861 + "type": "string", 862 + "description": "quota rule to query", 863 + "name": "quotarule", 864 + "in": "path", 865 + "required": true 866 + } 867 + ], 868 + "responses": { 869 + "200": { 870 + "$ref": "#/responses/QuotaRuleInfo" 871 + }, 872 + "400": { 873 + "$ref": "#/responses/error" 874 + }, 875 + "403": { 876 + "$ref": "#/responses/forbidden" 877 + }, 878 + "404": { 879 + "$ref": "#/responses/notFound" 880 + } 881 + } 882 + }, 883 + "delete": { 884 + "produces": [ 885 + "application/json" 886 + ], 887 + "tags": [ 888 + "admin" 889 + ], 890 + "summary": "Deletes a quota rule", 891 + "operationId": "adminDEleteQuotaRule", 892 + "parameters": [ 893 + { 894 + "type": "string", 895 + "description": "quota rule to delete", 896 + "name": "quotarule", 897 + "in": "path", 898 + "required": true 899 + } 900 + ], 901 + "responses": { 902 + "204": { 903 + "$ref": "#/responses/empty" 904 + }, 905 + "400": { 906 + "$ref": "#/responses/error" 907 + }, 908 + "403": { 909 + "$ref": "#/responses/forbidden" 910 + }, 911 + "404": { 912 + "$ref": "#/responses/notFound" 913 + } 914 + } 915 + }, 916 + "patch": { 917 + "produces": [ 918 + "application/json" 919 + ], 920 + "tags": [ 921 + "admin" 922 + ], 923 + "summary": "Change an existing quota rule", 924 + "operationId": "adminEditQuotaRule", 925 + "parameters": [ 926 + { 927 + "type": "string", 928 + "description": "Quota rule to change", 929 + "name": "quotarule", 930 + "in": "path", 931 + "required": true 932 + }, 933 + { 934 + "name": "rule", 935 + "in": "body", 936 + "required": true, 937 + "schema": { 938 + "$ref": "#/definitions/EditQuotaRuleOptions" 939 + } 940 + } 941 + ], 942 + "responses": { 943 + "200": { 944 + "$ref": "#/responses/QuotaRuleInfo" 945 + }, 946 + "400": { 947 + "$ref": "#/responses/error" 948 + }, 949 + "403": { 950 + "$ref": "#/responses/forbidden" 951 + }, 952 + "404": { 953 + "$ref": "#/responses/notFound" 954 + }, 955 + "422": { 956 + "$ref": "#/responses/validationError" 957 + } 958 + } 959 + } 960 + }, 454 961 "/admin/runners/registration-token": { 455 962 "get": { 456 963 "produces": [ ··· 866 1373 }, 867 1374 "403": { 868 1375 "$ref": "#/responses/forbidden" 1376 + }, 1377 + "422": { 1378 + "$ref": "#/responses/validationError" 1379 + } 1380 + } 1381 + } 1382 + }, 1383 + "/admin/users/{username}/quota": { 1384 + "get": { 1385 + "produces": [ 1386 + "application/json" 1387 + ], 1388 + "tags": [ 1389 + "admin" 1390 + ], 1391 + "summary": "Get the user's quota info", 1392 + "operationId": "adminGetUserQuota", 1393 + "parameters": [ 1394 + { 1395 + "type": "string", 1396 + "description": "username of user to query", 1397 + "name": "username", 1398 + "in": "path", 1399 + "required": true 1400 + } 1401 + ], 1402 + "responses": { 1403 + "200": { 1404 + "$ref": "#/responses/QuotaInfo" 1405 + }, 1406 + "400": { 1407 + "$ref": "#/responses/error" 1408 + }, 1409 + "403": { 1410 + "$ref": "#/responses/forbidden" 1411 + }, 1412 + "404": { 1413 + "$ref": "#/responses/notFound" 1414 + }, 1415 + "422": { 1416 + "$ref": "#/responses/validationError" 1417 + } 1418 + } 1419 + } 1420 + }, 1421 + "/admin/users/{username}/quota/groups": { 1422 + "post": { 1423 + "produces": [ 1424 + "application/json" 1425 + ], 1426 + "tags": [ 1427 + "admin" 1428 + ], 1429 + "summary": "Set the user's quota groups to a given list.", 1430 + "operationId": "adminSetUserQuotaGroups", 1431 + "parameters": [ 1432 + { 1433 + "type": "string", 1434 + "description": "username of the user to add to the quota group", 1435 + "name": "username", 1436 + "in": "path", 1437 + "required": true 1438 + }, 1439 + { 1440 + "description": "quota group to remove a user from", 1441 + "name": "groups", 1442 + "in": "body", 1443 + "required": true, 1444 + "schema": { 1445 + "$ref": "#/definitions/SetUserQuotaGroupsOptions" 1446 + } 1447 + } 1448 + ], 1449 + "responses": { 1450 + "204": { 1451 + "$ref": "#/responses/empty" 1452 + }, 1453 + "400": { 1454 + "$ref": "#/responses/error" 1455 + }, 1456 + "403": { 1457 + "$ref": "#/responses/forbidden" 1458 + }, 1459 + "404": { 1460 + "$ref": "#/responses/notFound" 869 1461 }, 870 1462 "422": { 871 1463 "$ref": "#/responses/validationError" ··· 2857 3449 "responses": { 2858 3450 "204": { 2859 3451 "$ref": "#/responses/empty" 3452 + }, 3453 + "403": { 3454 + "$ref": "#/responses/forbidden" 3455 + }, 3456 + "404": { 3457 + "$ref": "#/responses/notFound" 3458 + } 3459 + } 3460 + } 3461 + }, 3462 + "/orgs/{org}/quota": { 3463 + "get": { 3464 + "produces": [ 3465 + "application/json" 3466 + ], 3467 + "tags": [ 3468 + "organization" 3469 + ], 3470 + "summary": "Get quota information for an organization", 3471 + "operationId": "orgGetQuota", 3472 + "parameters": [ 3473 + { 3474 + "type": "string", 3475 + "description": "name of the organization", 3476 + "name": "org", 3477 + "in": "path", 3478 + "required": true 3479 + } 3480 + ], 3481 + "responses": { 3482 + "200": { 3483 + "$ref": "#/responses/QuotaInfo" 3484 + }, 3485 + "403": { 3486 + "$ref": "#/responses/forbidden" 3487 + }, 3488 + "404": { 3489 + "$ref": "#/responses/notFound" 3490 + } 3491 + } 3492 + } 3493 + }, 3494 + "/orgs/{org}/quota/artifacts": { 3495 + "get": { 3496 + "produces": [ 3497 + "application/json" 3498 + ], 3499 + "tags": [ 3500 + "organization" 3501 + ], 3502 + "summary": "List the artifacts affecting the organization's quota", 3503 + "operationId": "orgListQuotaArtifacts", 3504 + "parameters": [ 3505 + { 3506 + "type": "string", 3507 + "description": "name of the organization", 3508 + "name": "org", 3509 + "in": "path", 3510 + "required": true 3511 + }, 3512 + { 3513 + "type": "integer", 3514 + "description": "page number of results to return (1-based)", 3515 + "name": "page", 3516 + "in": "query" 3517 + }, 3518 + { 3519 + "type": "integer", 3520 + "description": "page size of results", 3521 + "name": "limit", 3522 + "in": "query" 3523 + } 3524 + ], 3525 + "responses": { 3526 + "200": { 3527 + "$ref": "#/responses/QuotaUsedArtifactList" 3528 + }, 3529 + "403": { 3530 + "$ref": "#/responses/forbidden" 3531 + }, 3532 + "404": { 3533 + "$ref": "#/responses/notFound" 3534 + } 3535 + } 3536 + } 3537 + }, 3538 + "/orgs/{org}/quota/attachments": { 3539 + "get": { 3540 + "produces": [ 3541 + "application/json" 3542 + ], 3543 + "tags": [ 3544 + "organization" 3545 + ], 3546 + "summary": "List the attachments affecting the organization's quota", 3547 + "operationId": "orgListQuotaAttachments", 3548 + "parameters": [ 3549 + { 3550 + "type": "string", 3551 + "description": "name of the organization", 3552 + "name": "org", 3553 + "in": "path", 3554 + "required": true 3555 + }, 3556 + { 3557 + "type": "integer", 3558 + "description": "page number of results to return (1-based)", 3559 + "name": "page", 3560 + "in": "query" 3561 + }, 3562 + { 3563 + "type": "integer", 3564 + "description": "page size of results", 3565 + "name": "limit", 3566 + "in": "query" 3567 + } 3568 + ], 3569 + "responses": { 3570 + "200": { 3571 + "$ref": "#/responses/QuotaUsedAttachmentList" 3572 + }, 3573 + "403": { 3574 + "$ref": "#/responses/forbidden" 3575 + }, 3576 + "404": { 3577 + "$ref": "#/responses/notFound" 3578 + } 3579 + } 3580 + } 3581 + }, 3582 + "/orgs/{org}/quota/check": { 3583 + "get": { 3584 + "produces": [ 3585 + "application/json" 3586 + ], 3587 + "tags": [ 3588 + "organization" 3589 + ], 3590 + "summary": "Check if the organization is over quota for a given subject", 3591 + "operationId": "orgCheckQuota", 3592 + "parameters": [ 3593 + { 3594 + "type": "string", 3595 + "description": "name of the organization", 3596 + "name": "org", 3597 + "in": "path", 3598 + "required": true 3599 + } 3600 + ], 3601 + "responses": { 3602 + "200": { 3603 + "$ref": "#/responses/boolean" 3604 + }, 3605 + "403": { 3606 + "$ref": "#/responses/forbidden" 3607 + }, 3608 + "404": { 3609 + "$ref": "#/responses/notFound" 3610 + }, 3611 + "422": { 3612 + "$ref": "#/responses/validationError" 3613 + } 3614 + } 3615 + } 3616 + }, 3617 + "/orgs/{org}/quota/packages": { 3618 + "get": { 3619 + "produces": [ 3620 + "application/json" 3621 + ], 3622 + "tags": [ 3623 + "organization" 3624 + ], 3625 + "summary": "List the packages affecting the organization's quota", 3626 + "operationId": "orgListQuotaPackages", 3627 + "parameters": [ 3628 + { 3629 + "type": "string", 3630 + "description": "name of the organization", 3631 + "name": "org", 3632 + "in": "path", 3633 + "required": true 3634 + }, 3635 + { 3636 + "type": "integer", 3637 + "description": "page number of results to return (1-based)", 3638 + "name": "page", 3639 + "in": "query" 3640 + }, 3641 + { 3642 + "type": "integer", 3643 + "description": "page size of results", 3644 + "name": "limit", 3645 + "in": "query" 3646 + } 3647 + ], 3648 + "responses": { 3649 + "200": { 3650 + "$ref": "#/responses/QuotaUsedPackageList" 2860 3651 }, 2861 3652 "403": { 2862 3653 "$ref": "#/responses/forbidden" ··· 17507 18298 } 17508 18299 } 17509 18300 }, 18301 + "/user/quota": { 18302 + "get": { 18303 + "produces": [ 18304 + "application/json" 18305 + ], 18306 + "tags": [ 18307 + "user" 18308 + ], 18309 + "summary": "Get quota information for the authenticated user", 18310 + "operationId": "userGetQuota", 18311 + "responses": { 18312 + "200": { 18313 + "$ref": "#/responses/QuotaInfo" 18314 + }, 18315 + "403": { 18316 + "$ref": "#/responses/forbidden" 18317 + } 18318 + } 18319 + } 18320 + }, 18321 + "/user/quota/artifacts": { 18322 + "get": { 18323 + "produces": [ 18324 + "application/json" 18325 + ], 18326 + "tags": [ 18327 + "user" 18328 + ], 18329 + "summary": "List the artifacts affecting the authenticated user's quota", 18330 + "operationId": "userListQuotaArtifacts", 18331 + "parameters": [ 18332 + { 18333 + "type": "integer", 18334 + "description": "page number of results to return (1-based)", 18335 + "name": "page", 18336 + "in": "query" 18337 + }, 18338 + { 18339 + "type": "integer", 18340 + "description": "page size of results", 18341 + "name": "limit", 18342 + "in": "query" 18343 + } 18344 + ], 18345 + "responses": { 18346 + "200": { 18347 + "$ref": "#/responses/QuotaUsedArtifactList" 18348 + }, 18349 + "403": { 18350 + "$ref": "#/responses/forbidden" 18351 + } 18352 + } 18353 + } 18354 + }, 18355 + "/user/quota/attachments": { 18356 + "get": { 18357 + "produces": [ 18358 + "application/json" 18359 + ], 18360 + "tags": [ 18361 + "user" 18362 + ], 18363 + "summary": "List the attachments affecting the authenticated user's quota", 18364 + "operationId": "userListQuotaAttachments", 18365 + "parameters": [ 18366 + { 18367 + "type": "integer", 18368 + "description": "page number of results to return (1-based)", 18369 + "name": "page", 18370 + "in": "query" 18371 + }, 18372 + { 18373 + "type": "integer", 18374 + "description": "page size of results", 18375 + "name": "limit", 18376 + "in": "query" 18377 + } 18378 + ], 18379 + "responses": { 18380 + "200": { 18381 + "$ref": "#/responses/QuotaUsedAttachmentList" 18382 + }, 18383 + "403": { 18384 + "$ref": "#/responses/forbidden" 18385 + } 18386 + } 18387 + } 18388 + }, 18389 + "/user/quota/check": { 18390 + "get": { 18391 + "produces": [ 18392 + "application/json" 18393 + ], 18394 + "tags": [ 18395 + "user" 18396 + ], 18397 + "summary": "Check if the authenticated user is over quota for a given subject", 18398 + "operationId": "userCheckQuota", 18399 + "responses": { 18400 + "200": { 18401 + "$ref": "#/responses/boolean" 18402 + }, 18403 + "403": { 18404 + "$ref": "#/responses/forbidden" 18405 + }, 18406 + "422": { 18407 + "$ref": "#/responses/validationError" 18408 + } 18409 + } 18410 + } 18411 + }, 18412 + "/user/quota/packages": { 18413 + "get": { 18414 + "produces": [ 18415 + "application/json" 18416 + ], 18417 + "tags": [ 18418 + "user" 18419 + ], 18420 + "summary": "List the packages affecting the authenticated user's quota", 18421 + "operationId": "userListQuotaPackages", 18422 + "parameters": [ 18423 + { 18424 + "type": "integer", 18425 + "description": "page number of results to return (1-based)", 18426 + "name": "page", 18427 + "in": "query" 18428 + }, 18429 + { 18430 + "type": "integer", 18431 + "description": "page size of results", 18432 + "name": "limit", 18433 + "in": "query" 18434 + } 18435 + ], 18436 + "responses": { 18437 + "200": { 18438 + "$ref": "#/responses/QuotaUsedPackageList" 18439 + }, 18440 + "403": { 18441 + "$ref": "#/responses/forbidden" 18442 + } 18443 + } 18444 + } 18445 + }, 17510 18446 "/user/repos": { 17511 18447 "get": { 17512 18448 "produces": [ ··· 20477 21413 }, 20478 21414 "x-go-package": "code.gitea.io/gitea/modules/structs" 20479 21415 }, 21416 + "CreateQuotaGroupOptions": { 21417 + "description": "CreateQutaGroupOptions represents the options for creating a quota group", 21418 + "type": "object", 21419 + "properties": { 21420 + "name": { 21421 + "description": "Name of the quota group to create", 21422 + "type": "string", 21423 + "x-go-name": "Name" 21424 + }, 21425 + "rules": { 21426 + "description": "Rules to add to the newly created group.\nIf a rule does not exist, it will be created.", 21427 + "type": "array", 21428 + "items": { 21429 + "$ref": "#/definitions/CreateQuotaRuleOptions" 21430 + }, 21431 + "x-go-name": "Rules" 21432 + } 21433 + }, 21434 + "x-go-package": "code.gitea.io/gitea/modules/structs" 21435 + }, 21436 + "CreateQuotaRuleOptions": { 21437 + "description": "CreateQuotaRuleOptions represents the options for creating a quota rule", 21438 + "type": "object", 21439 + "properties": { 21440 + "limit": { 21441 + "description": "The limit set by the rule", 21442 + "type": "integer", 21443 + "format": "int64", 21444 + "x-go-name": "Limit" 21445 + }, 21446 + "name": { 21447 + "description": "Name of the rule to create", 21448 + "type": "string", 21449 + "x-go-name": "Name" 21450 + }, 21451 + "subjects": { 21452 + "description": "The subjects affected by the rule", 21453 + "type": "array", 21454 + "items": { 21455 + "type": "string" 21456 + }, 21457 + "x-go-name": "Subjects" 21458 + } 21459 + }, 21460 + "x-go-package": "code.gitea.io/gitea/modules/structs" 21461 + }, 20480 21462 "CreateReleaseOption": { 20481 21463 "description": "CreateReleaseOption options when creating a release", 20482 21464 "type": "object", ··· 21427 22409 "unset_due_date": { 21428 22410 "type": "boolean", 21429 22411 "x-go-name": "RemoveDeadline" 22412 + } 22413 + }, 22414 + "x-go-package": "code.gitea.io/gitea/modules/structs" 22415 + }, 22416 + "EditQuotaRuleOptions": { 22417 + "description": "EditQuotaRuleOptions represents the options for editing a quota rule", 22418 + "type": "object", 22419 + "properties": { 22420 + "limit": { 22421 + "description": "The limit set by the rule", 22422 + "type": "integer", 22423 + "format": "int64", 22424 + "x-go-name": "Limit" 22425 + }, 22426 + "subjects": { 22427 + "description": "The subjects affected by the rule", 22428 + "type": "array", 22429 + "items": { 22430 + "type": "string" 22431 + }, 22432 + "x-go-name": "Subjects" 21430 22433 } 21431 22434 }, 21432 22435 "x-go-package": "code.gitea.io/gitea/modules/structs" ··· 24214 25217 }, 24215 25218 "x-go-package": "code.gitea.io/gitea/modules/structs" 24216 25219 }, 25220 + "QuotaGroup": { 25221 + "description": "QuotaGroup represents a quota group", 25222 + "type": "object", 25223 + "properties": { 25224 + "name": { 25225 + "description": "Name of the group", 25226 + "type": "string", 25227 + "x-go-name": "Name" 25228 + }, 25229 + "rules": { 25230 + "description": "Rules associated with the group", 25231 + "type": "array", 25232 + "items": { 25233 + "$ref": "#/definitions/QuotaRuleInfo" 25234 + }, 25235 + "x-go-name": "Rules" 25236 + } 25237 + }, 25238 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25239 + }, 25240 + "QuotaGroupList": { 25241 + "description": "QuotaGroupList represents a list of quota groups", 25242 + "type": "array", 25243 + "items": { 25244 + "$ref": "#/definitions/QuotaGroup" 25245 + }, 25246 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25247 + }, 25248 + "QuotaInfo": { 25249 + "description": "QuotaInfo represents information about a user's quota", 25250 + "type": "object", 25251 + "properties": { 25252 + "groups": { 25253 + "$ref": "#/definitions/QuotaGroupList" 25254 + }, 25255 + "used": { 25256 + "$ref": "#/definitions/QuotaUsed" 25257 + } 25258 + }, 25259 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25260 + }, 25261 + "QuotaRuleInfo": { 25262 + "description": "QuotaRuleInfo contains information about a quota rule", 25263 + "type": "object", 25264 + "properties": { 25265 + "limit": { 25266 + "description": "The limit set by the rule", 25267 + "type": "integer", 25268 + "format": "int64", 25269 + "x-go-name": "Limit" 25270 + }, 25271 + "name": { 25272 + "description": "Name of the rule (only shown to admins)", 25273 + "type": "string", 25274 + "x-go-name": "Name" 25275 + }, 25276 + "subjects": { 25277 + "description": "Subjects the rule affects", 25278 + "type": "array", 25279 + "items": { 25280 + "type": "string" 25281 + }, 25282 + "x-go-name": "Subjects" 25283 + } 25284 + }, 25285 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25286 + }, 25287 + "QuotaUsed": { 25288 + "description": "QuotaUsed represents the quota usage of a user", 25289 + "type": "object", 25290 + "properties": { 25291 + "size": { 25292 + "$ref": "#/definitions/QuotaUsedSize" 25293 + } 25294 + }, 25295 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25296 + }, 25297 + "QuotaUsedArtifact": { 25298 + "description": "QuotaUsedArtifact represents an artifact counting towards a user's quota", 25299 + "type": "object", 25300 + "properties": { 25301 + "html_url": { 25302 + "description": "HTML URL to the action run containing the artifact", 25303 + "type": "string", 25304 + "x-go-name": "HTMLURL" 25305 + }, 25306 + "name": { 25307 + "description": "Name of the artifact", 25308 + "type": "string", 25309 + "x-go-name": "Name" 25310 + }, 25311 + "size": { 25312 + "description": "Size of the artifact (compressed)", 25313 + "type": "integer", 25314 + "format": "int64", 25315 + "x-go-name": "Size" 25316 + } 25317 + }, 25318 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25319 + }, 25320 + "QuotaUsedArtifactList": { 25321 + "description": "QuotaUsedArtifactList represents a list of artifacts counting towards a user's quota", 25322 + "type": "array", 25323 + "items": { 25324 + "$ref": "#/definitions/QuotaUsedArtifact" 25325 + }, 25326 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25327 + }, 25328 + "QuotaUsedAttachment": { 25329 + "description": "QuotaUsedAttachment represents an attachment counting towards a user's quota", 25330 + "type": "object", 25331 + "properties": { 25332 + "api_url": { 25333 + "description": "API URL for the attachment", 25334 + "type": "string", 25335 + "x-go-name": "APIURL" 25336 + }, 25337 + "contained_in": { 25338 + "description": "Context for the attachment: URLs to the containing object", 25339 + "type": "object", 25340 + "properties": { 25341 + "api_url": { 25342 + "description": "API URL for the object that contains this attachment", 25343 + "type": "string", 25344 + "x-go-name": "APIURL" 25345 + }, 25346 + "html_url": { 25347 + "description": "HTML URL for the object that contains this attachment", 25348 + "type": "string", 25349 + "x-go-name": "HTMLURL" 25350 + } 25351 + }, 25352 + "x-go-name": "ContainedIn" 25353 + }, 25354 + "name": { 25355 + "description": "Filename of the attachment", 25356 + "type": "string", 25357 + "x-go-name": "Name" 25358 + }, 25359 + "size": { 25360 + "description": "Size of the attachment (in bytes)", 25361 + "type": "integer", 25362 + "format": "int64", 25363 + "x-go-name": "Size" 25364 + } 25365 + }, 25366 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25367 + }, 25368 + "QuotaUsedAttachmentList": { 25369 + "description": "QuotaUsedAttachmentList represents a list of attachment counting towards a user's quota", 25370 + "type": "array", 25371 + "items": { 25372 + "$ref": "#/definitions/QuotaUsedAttachment" 25373 + }, 25374 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25375 + }, 25376 + "QuotaUsedPackage": { 25377 + "description": "QuotaUsedPackage represents a package counting towards a user's quota", 25378 + "type": "object", 25379 + "properties": { 25380 + "html_url": { 25381 + "description": "HTML URL to the package version", 25382 + "type": "string", 25383 + "x-go-name": "HTMLURL" 25384 + }, 25385 + "name": { 25386 + "description": "Name of the package", 25387 + "type": "string", 25388 + "x-go-name": "Name" 25389 + }, 25390 + "size": { 25391 + "description": "Size of the package version", 25392 + "type": "integer", 25393 + "format": "int64", 25394 + "x-go-name": "Size" 25395 + }, 25396 + "type": { 25397 + "description": "Type of the package", 25398 + "type": "string", 25399 + "x-go-name": "Type" 25400 + }, 25401 + "version": { 25402 + "description": "Version of the package", 25403 + "type": "string", 25404 + "x-go-name": "Version" 25405 + } 25406 + }, 25407 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25408 + }, 25409 + "QuotaUsedPackageList": { 25410 + "description": "QuotaUsedPackageList represents a list of packages counting towards a user's quota", 25411 + "type": "array", 25412 + "items": { 25413 + "$ref": "#/definitions/QuotaUsedPackage" 25414 + }, 25415 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25416 + }, 25417 + "QuotaUsedSize": { 25418 + "description": "QuotaUsedSize represents the size-based quota usage of a user", 25419 + "type": "object", 25420 + "properties": { 25421 + "assets": { 25422 + "$ref": "#/definitions/QuotaUsedSizeAssets" 25423 + }, 25424 + "git": { 25425 + "$ref": "#/definitions/QuotaUsedSizeGit" 25426 + }, 25427 + "repos": { 25428 + "$ref": "#/definitions/QuotaUsedSizeRepos" 25429 + } 25430 + }, 25431 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25432 + }, 25433 + "QuotaUsedSizeAssets": { 25434 + "description": "QuotaUsedSizeAssets represents the size-based asset usage of a user", 25435 + "type": "object", 25436 + "properties": { 25437 + "artifacts": { 25438 + "description": "Storage size used for the user's artifacts", 25439 + "type": "integer", 25440 + "format": "int64", 25441 + "x-go-name": "Artifacts" 25442 + }, 25443 + "attachments": { 25444 + "$ref": "#/definitions/QuotaUsedSizeAssetsAttachments" 25445 + }, 25446 + "packages": { 25447 + "$ref": "#/definitions/QuotaUsedSizeAssetsPackages" 25448 + } 25449 + }, 25450 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25451 + }, 25452 + "QuotaUsedSizeAssetsAttachments": { 25453 + "description": "QuotaUsedSizeAssetsAttachments represents the size-based attachment quota usage of a user", 25454 + "type": "object", 25455 + "properties": { 25456 + "issues": { 25457 + "description": "Storage size used for the user's issue \u0026 comment attachments", 25458 + "type": "integer", 25459 + "format": "int64", 25460 + "x-go-name": "Issues" 25461 + }, 25462 + "releases": { 25463 + "description": "Storage size used for the user's release attachments", 25464 + "type": "integer", 25465 + "format": "int64", 25466 + "x-go-name": "Releases" 25467 + } 25468 + }, 25469 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25470 + }, 25471 + "QuotaUsedSizeAssetsPackages": { 25472 + "description": "QuotaUsedSizeAssetsPackages represents the size-based package quota usage of a user", 25473 + "type": "object", 25474 + "properties": { 25475 + "all": { 25476 + "description": "Storage suze used for the user's packages", 25477 + "type": "integer", 25478 + "format": "int64", 25479 + "x-go-name": "All" 25480 + } 25481 + }, 25482 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25483 + }, 25484 + "QuotaUsedSizeGit": { 25485 + "description": "QuotaUsedSizeGit represents the size-based git (lfs) quota usage of a user", 25486 + "type": "object", 25487 + "properties": { 25488 + "LFS": { 25489 + "description": "Storage size of the user's Git LFS objects", 25490 + "type": "integer", 25491 + "format": "int64" 25492 + } 25493 + }, 25494 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25495 + }, 25496 + "QuotaUsedSizeRepos": { 25497 + "description": "QuotaUsedSizeRepos represents the size-based repository quota usage of a user", 25498 + "type": "object", 25499 + "properties": { 25500 + "private": { 25501 + "description": "Storage size of the user's private repositories", 25502 + "type": "integer", 25503 + "format": "int64", 25504 + "x-go-name": "Private" 25505 + }, 25506 + "public": { 25507 + "description": "Storage size of the user's public repositories", 25508 + "type": "integer", 25509 + "format": "int64", 25510 + "x-go-name": "Public" 25511 + } 25512 + }, 25513 + "x-go-package": "code.gitea.io/gitea/modules/structs" 25514 + }, 24217 25515 "Reaction": { 24218 25516 "description": "Reaction contain one reaction", 24219 25517 "type": "object", ··· 24783 26081 "version": { 24784 26082 "type": "string", 24785 26083 "x-go-name": "Version" 26084 + } 26085 + }, 26086 + "x-go-package": "code.gitea.io/gitea/modules/structs" 26087 + }, 26088 + "SetUserQuotaGroupsOptions": { 26089 + "description": "SetUserQuotaGroupsOptions represents the quota groups of a user", 26090 + "type": "object", 26091 + "required": [ 26092 + "groups" 26093 + ], 26094 + "properties": { 26095 + "groups": { 26096 + "description": "Quota groups the user shall have", 26097 + "type": "array", 26098 + "items": { 26099 + "type": "string" 26100 + }, 26101 + "x-go-name": "Groups" 24786 26102 } 24787 26103 }, 24788 26104 "x-go-package": "code.gitea.io/gitea/modules/structs" ··· 26390 27706 } 26391 27707 } 26392 27708 }, 27709 + "QuotaGroup": { 27710 + "description": "QuotaGroup", 27711 + "schema": { 27712 + "$ref": "#/definitions/QuotaGroup" 27713 + } 27714 + }, 27715 + "QuotaGroupList": { 27716 + "description": "QuotaGroupList", 27717 + "schema": { 27718 + "$ref": "#/definitions/QuotaGroupList" 27719 + } 27720 + }, 27721 + "QuotaInfo": { 27722 + "description": "QuotaInfo", 27723 + "schema": { 27724 + "$ref": "#/definitions/QuotaInfo" 27725 + } 27726 + }, 27727 + "QuotaRuleInfo": { 27728 + "description": "QuotaRuleInfo", 27729 + "schema": { 27730 + "$ref": "#/definitions/QuotaRuleInfo" 27731 + } 27732 + }, 27733 + "QuotaRuleInfoList": { 27734 + "description": "QuotaRuleInfoList", 27735 + "schema": { 27736 + "type": "array", 27737 + "items": { 27738 + "$ref": "#/definitions/QuotaRuleInfo" 27739 + } 27740 + } 27741 + }, 27742 + "QuotaUsedArtifactList": { 27743 + "description": "QuotaUsedArtifactList", 27744 + "schema": { 27745 + "$ref": "#/definitions/QuotaUsedArtifactList" 27746 + } 27747 + }, 27748 + "QuotaUsedAttachmentList": { 27749 + "description": "QuotaUsedAttachmentList", 27750 + "schema": { 27751 + "$ref": "#/definitions/QuotaUsedAttachmentList" 27752 + } 27753 + }, 27754 + "QuotaUsedPackageList": { 27755 + "description": "QuotaUsedPackageList", 27756 + "schema": { 27757 + "$ref": "#/definitions/QuotaUsedPackageList" 27758 + } 27759 + }, 26393 27760 "Reaction": { 26394 27761 "description": "Reaction", 26395 27762 "schema": { ··· 26689 28056 } 26690 28057 } 26691 28058 }, 28059 + "boolean": { 28060 + "description": "Boolean" 28061 + }, 26692 28062 "conflict": { 26693 28063 "description": "APIConflict is a conflict empty response" 26694 28064 }, ··· 26737 28107 "parameterBodies": { 26738 28108 "description": "parameterBodies", 26739 28109 "schema": { 26740 - "$ref": "#/definitions/DispatchWorkflowOption" 28110 + "$ref": "#/definitions/SetUserQuotaGroupsOptions" 26741 28111 } 26742 28112 }, 26743 28113 "redirect": {
+846
tests/integration/api_quota_management_test.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package integration 5 + 6 + import ( 7 + "fmt" 8 + "net/http" 9 + "testing" 10 + 11 + auth_model "code.gitea.io/gitea/models/auth" 12 + "code.gitea.io/gitea/models/db" 13 + quota_model "code.gitea.io/gitea/models/quota" 14 + "code.gitea.io/gitea/models/unittest" 15 + user_model "code.gitea.io/gitea/models/user" 16 + "code.gitea.io/gitea/modules/setting" 17 + api "code.gitea.io/gitea/modules/structs" 18 + "code.gitea.io/gitea/modules/test" 19 + "code.gitea.io/gitea/routers" 20 + "code.gitea.io/gitea/tests" 21 + 22 + "github.com/stretchr/testify/assert" 23 + "github.com/stretchr/testify/require" 24 + ) 25 + 26 + func TestAPIQuotaDisabled(t *testing.T) { 27 + defer tests.PrepareTestEnv(t)() 28 + defer test.MockVariableValue(&setting.Quota.Enabled, false)() 29 + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 30 + 31 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 32 + session := loginUser(t, user.Name) 33 + 34 + req := NewRequest(t, "GET", "/api/v1/user/quota") 35 + session.MakeRequest(t, req, http.StatusNotFound) 36 + } 37 + 38 + func apiCreateUser(t *testing.T, username string) func() { 39 + t.Helper() 40 + 41 + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 42 + session := loginUser(t, admin.Name) 43 + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) 44 + 45 + mustChangePassword := false 46 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users", api.CreateUserOption{ 47 + Email: "api+" + username + "@example.com", 48 + Username: username, 49 + Password: "password", 50 + MustChangePassword: &mustChangePassword, 51 + }).AddTokenAuth(token) 52 + session.MakeRequest(t, req, http.StatusCreated) 53 + 54 + return func() { 55 + req := NewRequest(t, "DELETE", "/api/v1/admin/users/"+username+"?purge=true").AddTokenAuth(token) 56 + session.MakeRequest(t, req, http.StatusNoContent) 57 + } 58 + } 59 + 60 + func TestAPIQuotaCreateGroupWithRules(t *testing.T) { 61 + defer tests.PrepareTestEnv(t)() 62 + defer test.MockVariableValue(&setting.Quota.Enabled, true)() 63 + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 64 + 65 + // Create two rules in advance 66 + unlimited := int64(-1) 67 + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ 68 + Name: "unlimited", 69 + Limit: &unlimited, 70 + Subjects: []string{"size:all"}, 71 + })() 72 + zero := int64(0) 73 + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ 74 + Name: "deny-git-lfs", 75 + Limit: &zero, 76 + Subjects: []string{"size:git:lfs"}, 77 + })() 78 + 79 + // Log in as admin 80 + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 81 + adminSession := loginUser(t, admin.Name) 82 + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) 83 + 84 + // Create a new group, with rules specified 85 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ 86 + Name: "group-with-rules", 87 + Rules: []api.CreateQuotaRuleOptions{ 88 + // First: an existing group, unlimited, name only 89 + { 90 + Name: "unlimited", 91 + }, 92 + // Second: an existing group, deny-git-lfs, with different params 93 + { 94 + Name: "deny-git-lfs", 95 + Limit: &unlimited, 96 + }, 97 + // Third: an entirely new group 98 + { 99 + Name: "new-rule", 100 + Subjects: []string{"size:assets:all"}, 101 + }, 102 + }, 103 + }).AddTokenAuth(adminToken) 104 + resp := adminSession.MakeRequest(t, req, http.StatusCreated) 105 + defer func() { 106 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/group-with-rules").AddTokenAuth(adminToken) 107 + adminSession.MakeRequest(t, req, http.StatusNoContent) 108 + 109 + req = NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/new-rule").AddTokenAuth(adminToken) 110 + adminSession.MakeRequest(t, req, http.StatusNoContent) 111 + }() 112 + 113 + // Verify that we created a group with rules included 114 + var q api.QuotaGroup 115 + DecodeJSON(t, resp, &q) 116 + 117 + assert.Equal(t, "group-with-rules", q.Name) 118 + assert.Len(t, q.Rules, 3) 119 + 120 + // Verify that the previously existing rules are unchanged 121 + rule, err := quota_model.GetRuleByName(db.DefaultContext, "unlimited") 122 + require.NoError(t, err) 123 + assert.NotNil(t, rule) 124 + assert.EqualValues(t, -1, rule.Limit) 125 + assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll}, rule.Subjects) 126 + 127 + rule, err = quota_model.GetRuleByName(db.DefaultContext, "deny-git-lfs") 128 + require.NoError(t, err) 129 + assert.NotNil(t, rule) 130 + assert.EqualValues(t, 0, rule.Limit) 131 + assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeGitLFS}, rule.Subjects) 132 + 133 + // Verify that the new rule was also created 134 + rule, err = quota_model.GetRuleByName(db.DefaultContext, "new-rule") 135 + require.NoError(t, err) 136 + assert.NotNil(t, rule) 137 + assert.EqualValues(t, 0, rule.Limit) 138 + assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAssetsAll}, rule.Subjects) 139 + 140 + t.Run("invalid rule spec", func(t *testing.T) { 141 + defer tests.PrintCurrentTest(t)() 142 + 143 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ 144 + Name: "group-with-invalid-rule-spec", 145 + Rules: []api.CreateQuotaRuleOptions{ 146 + { 147 + Name: "rule-with-wrong-spec", 148 + Subjects: []string{"valid:false"}, 149 + }, 150 + }, 151 + }).AddTokenAuth(adminToken) 152 + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) 153 + }) 154 + } 155 + 156 + func TestAPIQuotaEmptyState(t *testing.T) { 157 + defer tests.PrepareTestEnv(t)() 158 + defer test.MockVariableValue(&setting.Quota.Enabled, true)() 159 + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 160 + 161 + username := "quota-empty-user" 162 + defer apiCreateUser(t, username)() 163 + session := loginUser(t, username) 164 + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) 165 + 166 + t.Run("#/admin/users/quota-empty-user/quota", func(t *testing.T) { 167 + defer tests.PrintCurrentTest(t)() 168 + 169 + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 170 + adminSession := loginUser(t, admin.Name) 171 + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) 172 + 173 + req := NewRequest(t, "GET", "/api/v1/admin/users/quota-empty-user/quota").AddTokenAuth(adminToken) 174 + resp := adminSession.MakeRequest(t, req, http.StatusOK) 175 + 176 + var q api.QuotaInfo 177 + DecodeJSON(t, resp, &q) 178 + 179 + assert.EqualValues(t, api.QuotaUsed{}, q.Used) 180 + assert.Empty(t, q.Groups) 181 + }) 182 + 183 + t.Run("#/user/quota", func(t *testing.T) { 184 + defer tests.PrintCurrentTest(t)() 185 + 186 + req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(token) 187 + resp := session.MakeRequest(t, req, http.StatusOK) 188 + 189 + var q api.QuotaInfo 190 + DecodeJSON(t, resp, &q) 191 + 192 + assert.EqualValues(t, api.QuotaUsed{}, q.Used) 193 + assert.Empty(t, q.Groups) 194 + 195 + t.Run("#/user/quota/artifacts", func(t *testing.T) { 196 + defer tests.PrintCurrentTest(t)() 197 + 198 + req := NewRequest(t, "GET", "/api/v1/user/quota/artifacts").AddTokenAuth(token) 199 + resp := session.MakeRequest(t, req, http.StatusOK) 200 + 201 + var q api.QuotaUsedArtifactList 202 + DecodeJSON(t, resp, &q) 203 + 204 + assert.Empty(t, q) 205 + }) 206 + 207 + t.Run("#/user/quota/attachments", func(t *testing.T) { 208 + defer tests.PrintCurrentTest(t)() 209 + 210 + req := NewRequest(t, "GET", "/api/v1/user/quota/attachments").AddTokenAuth(token) 211 + resp := session.MakeRequest(t, req, http.StatusOK) 212 + 213 + var q api.QuotaUsedAttachmentList 214 + DecodeJSON(t, resp, &q) 215 + 216 + assert.Empty(t, q) 217 + }) 218 + 219 + t.Run("#/user/quota/packages", func(t *testing.T) { 220 + defer tests.PrintCurrentTest(t)() 221 + 222 + req := NewRequest(t, "GET", "/api/v1/user/quota/packages").AddTokenAuth(token) 223 + resp := session.MakeRequest(t, req, http.StatusOK) 224 + 225 + var q api.QuotaUsedPackageList 226 + DecodeJSON(t, resp, &q) 227 + 228 + assert.Empty(t, q) 229 + }) 230 + }) 231 + } 232 + 233 + func createQuotaRule(t *testing.T, opts api.CreateQuotaRuleOptions) func() { 234 + t.Helper() 235 + 236 + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 237 + adminSession := loginUser(t, admin.Name) 238 + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) 239 + 240 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", opts).AddTokenAuth(adminToken) 241 + adminSession.MakeRequest(t, req, http.StatusCreated) 242 + 243 + return func() { 244 + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/rules/%s", opts.Name).AddTokenAuth(adminToken) 245 + adminSession.MakeRequest(t, req, http.StatusNoContent) 246 + } 247 + } 248 + 249 + func createQuotaGroup(t *testing.T, name string) func() { 250 + t.Helper() 251 + 252 + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 253 + adminSession := loginUser(t, admin.Name) 254 + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) 255 + 256 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ 257 + Name: name, 258 + }).AddTokenAuth(adminToken) 259 + adminSession.MakeRequest(t, req, http.StatusCreated) 260 + 261 + return func() { 262 + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s", name).AddTokenAuth(adminToken) 263 + adminSession.MakeRequest(t, req, http.StatusNoContent) 264 + } 265 + } 266 + 267 + func TestAPIQuotaAdminRoutesRules(t *testing.T) { 268 + defer tests.PrepareTestEnv(t)() 269 + defer test.MockVariableValue(&setting.Quota.Enabled, true)() 270 + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 271 + 272 + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 273 + adminSession := loginUser(t, admin.Name) 274 + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) 275 + 276 + zero := int64(0) 277 + oneKb := int64(1024) 278 + 279 + t.Run("adminCreateQuotaRule", func(t *testing.T) { 280 + defer tests.PrintCurrentTest(t)() 281 + 282 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", api.CreateQuotaRuleOptions{ 283 + Name: "deny-all", 284 + Limit: &zero, 285 + Subjects: []string{"size:all"}, 286 + }).AddTokenAuth(adminToken) 287 + resp := adminSession.MakeRequest(t, req, http.StatusCreated) 288 + defer func() { 289 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/deny-all").AddTokenAuth(adminToken) 290 + adminSession.MakeRequest(t, req, http.StatusNoContent) 291 + }() 292 + 293 + var q api.QuotaRuleInfo 294 + DecodeJSON(t, resp, &q) 295 + 296 + assert.Equal(t, "deny-all", q.Name) 297 + assert.EqualValues(t, 0, q.Limit) 298 + assert.EqualValues(t, []string{"size:all"}, q.Subjects) 299 + 300 + rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all") 301 + require.NoError(t, err) 302 + assert.EqualValues(t, 0, rule.Limit) 303 + 304 + t.Run("unhappy path", func(t *testing.T) { 305 + t.Run("missing options", func(t *testing.T) { 306 + defer tests.PrintCurrentTest(t)() 307 + 308 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", nil).AddTokenAuth(adminToken) 309 + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) 310 + }) 311 + 312 + t.Run("invalid subjects", func(t *testing.T) { 313 + defer tests.PrintCurrentTest(t)() 314 + 315 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", api.CreateQuotaRuleOptions{ 316 + Name: "invalid-subjects", 317 + Limit: &zero, 318 + Subjects: []string{"valid:false"}, 319 + }).AddTokenAuth(adminToken) 320 + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) 321 + }) 322 + 323 + t.Run("trying to add an existing rule", func(t *testing.T) { 324 + defer tests.PrintCurrentTest(t)() 325 + 326 + rule := api.CreateQuotaRuleOptions{ 327 + Name: "double-rule", 328 + Limit: &zero, 329 + } 330 + 331 + defer createQuotaRule(t, rule)() 332 + 333 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", rule).AddTokenAuth(adminToken) 334 + adminSession.MakeRequest(t, req, http.StatusConflict) 335 + }) 336 + }) 337 + }) 338 + 339 + t.Run("adminDeleteQuotaRule", func(t *testing.T) { 340 + defer tests.PrintCurrentTest(t)() 341 + 342 + createQuotaRule(t, api.CreateQuotaRuleOptions{ 343 + Name: "deny-all", 344 + Limit: &zero, 345 + Subjects: []string{"size:all"}, 346 + }) 347 + 348 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/deny-all").AddTokenAuth(adminToken) 349 + adminSession.MakeRequest(t, req, http.StatusNoContent) 350 + 351 + rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all") 352 + require.NoError(t, err) 353 + assert.Nil(t, rule) 354 + 355 + t.Run("unhappy path", func(t *testing.T) { 356 + t.Run("nonexistent rule", func(t *testing.T) { 357 + defer tests.PrintCurrentTest(t)() 358 + 359 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/does-not-exist").AddTokenAuth(adminToken) 360 + adminSession.MakeRequest(t, req, http.StatusNotFound) 361 + }) 362 + }) 363 + }) 364 + 365 + t.Run("adminEditQuotaRule", func(t *testing.T) { 366 + defer tests.PrintCurrentTest(t)() 367 + 368 + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ 369 + Name: "deny-all", 370 + Limit: &zero, 371 + Subjects: []string{"size:all"}, 372 + })() 373 + 374 + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", api.EditQuotaRuleOptions{ 375 + Limit: &oneKb, 376 + }).AddTokenAuth(adminToken) 377 + resp := adminSession.MakeRequest(t, req, http.StatusOK) 378 + 379 + var q api.QuotaRuleInfo 380 + DecodeJSON(t, resp, &q) 381 + assert.EqualValues(t, 1024, q.Limit) 382 + 383 + rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all") 384 + require.NoError(t, err) 385 + assert.EqualValues(t, 1024, rule.Limit) 386 + 387 + t.Run("no options", func(t *testing.T) { 388 + defer tests.PrintCurrentTest(t)() 389 + 390 + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", nil).AddTokenAuth(adminToken) 391 + adminSession.MakeRequest(t, req, http.StatusOK) 392 + }) 393 + 394 + t.Run("unhappy path", func(t *testing.T) { 395 + t.Run("nonexistent rule", func(t *testing.T) { 396 + defer tests.PrintCurrentTest(t)() 397 + 398 + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/does-not-exist", api.EditQuotaRuleOptions{ 399 + Limit: &oneKb, 400 + }).AddTokenAuth(adminToken) 401 + adminSession.MakeRequest(t, req, http.StatusNotFound) 402 + }) 403 + 404 + t.Run("invalid subjects", func(t *testing.T) { 405 + defer tests.PrintCurrentTest(t)() 406 + 407 + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", api.EditQuotaRuleOptions{ 408 + Subjects: &[]string{"valid:false"}, 409 + }).AddTokenAuth(adminToken) 410 + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) 411 + }) 412 + }) 413 + }) 414 + 415 + t.Run("adminListQuotaRules", func(t *testing.T) { 416 + defer tests.PrintCurrentTest(t)() 417 + 418 + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ 419 + Name: "deny-all", 420 + Limit: &zero, 421 + Subjects: []string{"size:all"}, 422 + })() 423 + 424 + req := NewRequest(t, "GET", "/api/v1/admin/quota/rules").AddTokenAuth(adminToken) 425 + resp := adminSession.MakeRequest(t, req, http.StatusOK) 426 + 427 + var rules []api.QuotaRuleInfo 428 + DecodeJSON(t, resp, &rules) 429 + 430 + assert.Len(t, rules, 1) 431 + assert.Equal(t, "deny-all", rules[0].Name) 432 + assert.EqualValues(t, 0, rules[0].Limit) 433 + }) 434 + } 435 + 436 + func TestAPIQuotaAdminRoutesGroups(t *testing.T) { 437 + defer tests.PrepareTestEnv(t)() 438 + defer test.MockVariableValue(&setting.Quota.Enabled, true)() 439 + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 440 + 441 + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 442 + adminSession := loginUser(t, admin.Name) 443 + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) 444 + 445 + zero := int64(0) 446 + 447 + ruleDenyAll := api.CreateQuotaRuleOptions{ 448 + Name: "deny-all", 449 + Limit: &zero, 450 + Subjects: []string{"size:all"}, 451 + } 452 + 453 + username := "quota-test-user" 454 + defer apiCreateUser(t, username)() 455 + 456 + t.Run("adminCreateQuotaGroup", func(t *testing.T) { 457 + defer tests.PrintCurrentTest(t)() 458 + 459 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ 460 + Name: "default", 461 + }).AddTokenAuth(adminToken) 462 + resp := adminSession.MakeRequest(t, req, http.StatusCreated) 463 + defer func() { 464 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken) 465 + adminSession.MakeRequest(t, req, http.StatusNoContent) 466 + }() 467 + 468 + var q api.QuotaGroup 469 + DecodeJSON(t, resp, &q) 470 + 471 + assert.Equal(t, "default", q.Name) 472 + assert.Empty(t, q.Rules) 473 + 474 + group, err := quota_model.GetGroupByName(db.DefaultContext, "default") 475 + require.NoError(t, err) 476 + assert.Equal(t, "default", group.Name) 477 + assert.Empty(t, group.Rules) 478 + 479 + t.Run("unhappy path", func(t *testing.T) { 480 + t.Run("missing options", func(t *testing.T) { 481 + defer tests.PrintCurrentTest(t)() 482 + 483 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", nil).AddTokenAuth(adminToken) 484 + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) 485 + }) 486 + 487 + t.Run("trying to add an existing group", func(t *testing.T) { 488 + defer tests.PrintCurrentTest(t)() 489 + 490 + defer createQuotaGroup(t, "duplicate")() 491 + 492 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ 493 + Name: "duplicate", 494 + }).AddTokenAuth(adminToken) 495 + adminSession.MakeRequest(t, req, http.StatusConflict) 496 + }) 497 + }) 498 + }) 499 + 500 + t.Run("adminDeleteQuotaGroup", func(t *testing.T) { 501 + defer tests.PrintCurrentTest(t)() 502 + 503 + createQuotaGroup(t, "default") 504 + 505 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken) 506 + adminSession.MakeRequest(t, req, http.StatusNoContent) 507 + 508 + group, err := quota_model.GetGroupByName(db.DefaultContext, "default") 509 + require.NoError(t, err) 510 + assert.Nil(t, group) 511 + 512 + t.Run("unhappy path", func(t *testing.T) { 513 + t.Run("non-existing group", func(t *testing.T) { 514 + defer tests.PrintCurrentTest(t)() 515 + 516 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist").AddTokenAuth(adminToken) 517 + adminSession.MakeRequest(t, req, http.StatusNotFound) 518 + }) 519 + }) 520 + }) 521 + 522 + t.Run("adminAddRuleToQuotaGroup", func(t *testing.T) { 523 + defer tests.PrintCurrentTest(t)() 524 + defer createQuotaGroup(t, "default")() 525 + defer createQuotaRule(t, ruleDenyAll)() 526 + 527 + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) 528 + adminSession.MakeRequest(t, req, http.StatusNoContent) 529 + 530 + group, err := quota_model.GetGroupByName(db.DefaultContext, "default") 531 + require.NoError(t, err) 532 + assert.Len(t, group.Rules, 1) 533 + assert.Equal(t, "deny-all", group.Rules[0].Name) 534 + 535 + t.Run("unhappy path", func(t *testing.T) { 536 + t.Run("non-existing group", func(t *testing.T) { 537 + defer tests.PrintCurrentTest(t)() 538 + 539 + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/does-not-exist/rules/deny-all").AddTokenAuth(adminToken) 540 + adminSession.MakeRequest(t, req, http.StatusNotFound) 541 + }) 542 + 543 + t.Run("non-existing rule", func(t *testing.T) { 544 + defer tests.PrintCurrentTest(t)() 545 + 546 + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/does-not-exist").AddTokenAuth(adminToken) 547 + adminSession.MakeRequest(t, req, http.StatusNotFound) 548 + }) 549 + }) 550 + }) 551 + 552 + t.Run("adminRemoveRuleFromQuotaGroup", func(t *testing.T) { 553 + defer tests.PrintCurrentTest(t)() 554 + defer createQuotaGroup(t, "default")() 555 + defer createQuotaRule(t, ruleDenyAll)() 556 + 557 + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) 558 + adminSession.MakeRequest(t, req, http.StatusNoContent) 559 + 560 + req = NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) 561 + adminSession.MakeRequest(t, req, http.StatusNoContent) 562 + 563 + group, err := quota_model.GetGroupByName(db.DefaultContext, "default") 564 + require.NoError(t, err) 565 + assert.Equal(t, "default", group.Name) 566 + assert.Empty(t, group.Rules) 567 + 568 + t.Run("unhappy path", func(t *testing.T) { 569 + t.Run("non-existing group", func(t *testing.T) { 570 + defer tests.PrintCurrentTest(t)() 571 + 572 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist/rules/deny-all").AddTokenAuth(adminToken) 573 + adminSession.MakeRequest(t, req, http.StatusNotFound) 574 + }) 575 + 576 + t.Run("non-existing rule", func(t *testing.T) { 577 + defer tests.PrintCurrentTest(t)() 578 + 579 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/does-not-exist").AddTokenAuth(adminToken) 580 + adminSession.MakeRequest(t, req, http.StatusNotFound) 581 + }) 582 + 583 + t.Run("rule not in group", func(t *testing.T) { 584 + defer tests.PrintCurrentTest(t)() 585 + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ 586 + Name: "rule-not-in-group", 587 + Limit: &zero, 588 + })() 589 + 590 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/rule-not-in-group").AddTokenAuth(adminToken) 591 + adminSession.MakeRequest(t, req, http.StatusNotFound) 592 + }) 593 + }) 594 + }) 595 + 596 + t.Run("adminGetQuotaGroup", func(t *testing.T) { 597 + defer tests.PrintCurrentTest(t)() 598 + defer createQuotaGroup(t, "default")() 599 + defer createQuotaRule(t, ruleDenyAll)() 600 + 601 + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) 602 + adminSession.MakeRequest(t, req, http.StatusNoContent) 603 + 604 + req = NewRequest(t, "GET", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken) 605 + resp := adminSession.MakeRequest(t, req, http.StatusOK) 606 + 607 + var q api.QuotaGroup 608 + DecodeJSON(t, resp, &q) 609 + 610 + assert.Equal(t, "default", q.Name) 611 + assert.Len(t, q.Rules, 1) 612 + assert.Equal(t, "deny-all", q.Rules[0].Name) 613 + 614 + t.Run("unhappy path", func(t *testing.T) { 615 + t.Run("non-existing group", func(t *testing.T) { 616 + defer tests.PrintCurrentTest(t)() 617 + 618 + req := NewRequest(t, "GET", "/api/v1/admin/quota/groups/does-not-exist").AddTokenAuth(adminToken) 619 + adminSession.MakeRequest(t, req, http.StatusNotFound) 620 + }) 621 + }) 622 + }) 623 + 624 + t.Run("adminListQuotaGroups", func(t *testing.T) { 625 + defer tests.PrintCurrentTest(t)() 626 + defer createQuotaGroup(t, "default")() 627 + defer createQuotaRule(t, ruleDenyAll)() 628 + 629 + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) 630 + adminSession.MakeRequest(t, req, http.StatusNoContent) 631 + 632 + req = NewRequest(t, "GET", "/api/v1/admin/quota/groups").AddTokenAuth(adminToken) 633 + resp := adminSession.MakeRequest(t, req, http.StatusOK) 634 + 635 + var q api.QuotaGroupList 636 + DecodeJSON(t, resp, &q) 637 + 638 + assert.Len(t, q, 1) 639 + assert.Equal(t, "default", q[0].Name) 640 + assert.Len(t, q[0].Rules, 1) 641 + assert.Equal(t, "deny-all", q[0].Rules[0].Name) 642 + }) 643 + 644 + t.Run("adminAddUserToQuotaGroup", func(t *testing.T) { 645 + defer tests.PrintCurrentTest(t)() 646 + defer createQuotaGroup(t, "default")() 647 + 648 + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken) 649 + adminSession.MakeRequest(t, req, http.StatusNoContent) 650 + 651 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) 652 + 653 + groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID) 654 + require.NoError(t, err) 655 + assert.Len(t, groups, 1) 656 + assert.Equal(t, "default", groups[0].Name) 657 + 658 + t.Run("unhappy path", func(t *testing.T) { 659 + t.Run("non-existing group", func(t *testing.T) { 660 + defer tests.PrintCurrentTest(t)() 661 + 662 + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/does-not-exist/users/%s", username).AddTokenAuth(adminToken) 663 + adminSession.MakeRequest(t, req, http.StatusNotFound) 664 + }) 665 + 666 + t.Run("non-existing user", func(t *testing.T) { 667 + defer tests.PrintCurrentTest(t)() 668 + 669 + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/this-user-does-not-exist").AddTokenAuth(adminToken) 670 + adminSession.MakeRequest(t, req, http.StatusNotFound) 671 + }) 672 + 673 + t.Run("user already added", func(t *testing.T) { 674 + defer tests.PrintCurrentTest(t)() 675 + 676 + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken) 677 + adminSession.MakeRequest(t, req, http.StatusNoContent) 678 + 679 + req = NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken) 680 + adminSession.MakeRequest(t, req, http.StatusConflict) 681 + }) 682 + }) 683 + }) 684 + 685 + t.Run("adminRemoveUserFromQuotaGroup", func(t *testing.T) { 686 + defer tests.PrintCurrentTest(t)() 687 + defer createQuotaGroup(t, "default")() 688 + 689 + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken) 690 + adminSession.MakeRequest(t, req, http.StatusNoContent) 691 + 692 + req = NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken) 693 + adminSession.MakeRequest(t, req, http.StatusNoContent) 694 + 695 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) 696 + groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID) 697 + require.NoError(t, err) 698 + assert.Empty(t, groups) 699 + 700 + t.Run("unhappy path", func(t *testing.T) { 701 + t.Run("non-existing group", func(t *testing.T) { 702 + defer tests.PrintCurrentTest(t)() 703 + 704 + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist/users/%s", username).AddTokenAuth(adminToken) 705 + adminSession.MakeRequest(t, req, http.StatusNotFound) 706 + }) 707 + 708 + t.Run("non-existing user", func(t *testing.T) { 709 + defer tests.PrintCurrentTest(t)() 710 + 711 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/users/does-not-exist").AddTokenAuth(adminToken) 712 + adminSession.MakeRequest(t, req, http.StatusNotFound) 713 + }) 714 + 715 + t.Run("user not in group", func(t *testing.T) { 716 + defer tests.PrintCurrentTest(t)() 717 + 718 + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken) 719 + adminSession.MakeRequest(t, req, http.StatusNotFound) 720 + }) 721 + }) 722 + }) 723 + 724 + t.Run("adminListUsersInQuotaGroup", func(t *testing.T) { 725 + defer tests.PrintCurrentTest(t)() 726 + defer createQuotaGroup(t, "default")() 727 + 728 + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken) 729 + adminSession.MakeRequest(t, req, http.StatusNoContent) 730 + 731 + req = NewRequest(t, "GET", "/api/v1/admin/quota/groups/default/users").AddTokenAuth(adminToken) 732 + resp := adminSession.MakeRequest(t, req, http.StatusOK) 733 + 734 + var q []api.User 735 + DecodeJSON(t, resp, &q) 736 + 737 + assert.Len(t, q, 1) 738 + assert.Equal(t, username, q[0].UserName) 739 + 740 + t.Run("unhappy path", func(t *testing.T) { 741 + t.Run("non-existing group", func(t *testing.T) { 742 + defer tests.PrintCurrentTest(t)() 743 + 744 + req := NewRequest(t, "GET", "/api/v1/admin/quota/groups/does-not-exist/users").AddTokenAuth(adminToken) 745 + adminSession.MakeRequest(t, req, http.StatusNotFound) 746 + }) 747 + }) 748 + }) 749 + 750 + t.Run("adminSetUserQuotaGroups", func(t *testing.T) { 751 + defer tests.PrintCurrentTest(t)() 752 + defer createQuotaGroup(t, "default")() 753 + defer createQuotaGroup(t, "test-1")() 754 + defer createQuotaGroup(t, "test-2")() 755 + 756 + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/admin/users/%s/quota/groups", username), api.SetUserQuotaGroupsOptions{ 757 + Groups: &[]string{"default", "test-1", "test-2"}, 758 + }).AddTokenAuth(adminToken) 759 + adminSession.MakeRequest(t, req, http.StatusNoContent) 760 + 761 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) 762 + 763 + groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID) 764 + require.NoError(t, err) 765 + assert.Len(t, groups, 3) 766 + 767 + t.Run("unhappy path", func(t *testing.T) { 768 + t.Run("non-existing user", func(t *testing.T) { 769 + defer tests.PrintCurrentTest(t)() 770 + 771 + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/does-not-exist/quota/groups", api.SetUserQuotaGroupsOptions{ 772 + Groups: &[]string{"default", "test-1", "test-2"}, 773 + }).AddTokenAuth(adminToken) 774 + adminSession.MakeRequest(t, req, http.StatusNotFound) 775 + }) 776 + 777 + t.Run("non-existing group", func(t *testing.T) { 778 + defer tests.PrintCurrentTest(t)() 779 + 780 + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/admin/users/%s/quota/groups", username), api.SetUserQuotaGroupsOptions{ 781 + Groups: &[]string{"default", "test-1", "test-2", "this-group-does-not-exist"}, 782 + }).AddTokenAuth(adminToken) 783 + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) 784 + }) 785 + }) 786 + }) 787 + } 788 + 789 + func TestAPIQuotaUserRoutes(t *testing.T) { 790 + defer tests.PrepareTestEnv(t)() 791 + defer test.MockVariableValue(&setting.Quota.Enabled, true)() 792 + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 793 + 794 + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) 795 + adminSession := loginUser(t, admin.Name) 796 + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) 797 + 798 + // Create a test user 799 + username := "quota-test-user-routes" 800 + defer apiCreateUser(t, username)() 801 + session := loginUser(t, username) 802 + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) 803 + 804 + // Set up rules & groups for the user 805 + defer createQuotaGroup(t, "user-routes-deny")() 806 + defer createQuotaGroup(t, "user-routes-1kb")() 807 + 808 + zero := int64(0) 809 + ruleDenyAll := api.CreateQuotaRuleOptions{ 810 + Name: "user-routes-deny-all", 811 + Limit: &zero, 812 + Subjects: []string{"size:all"}, 813 + } 814 + defer createQuotaRule(t, ruleDenyAll)() 815 + oneKb := int64(1024) 816 + rule1KbStuff := api.CreateQuotaRuleOptions{ 817 + Name: "user-routes-1kb", 818 + Limit: &oneKb, 819 + Subjects: []string{"size:assets:attachments:releases", "size:assets:packages:all", "size:git:lfs"}, 820 + } 821 + defer createQuotaRule(t, rule1KbStuff)() 822 + 823 + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/user-routes-deny/rules/user-routes-deny-all").AddTokenAuth(adminToken) 824 + adminSession.MakeRequest(t, req, http.StatusNoContent) 825 + req = NewRequest(t, "PUT", "/api/v1/admin/quota/groups/user-routes-1kb/rules/user-routes-1kb").AddTokenAuth(adminToken) 826 + adminSession.MakeRequest(t, req, http.StatusNoContent) 827 + 828 + req = NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/user-routes-deny/users/%s", username).AddTokenAuth(adminToken) 829 + adminSession.MakeRequest(t, req, http.StatusNoContent) 830 + req = NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/user-routes-1kb/users/%s", username).AddTokenAuth(adminToken) 831 + adminSession.MakeRequest(t, req, http.StatusNoContent) 832 + 833 + t.Run("userGetQuota", func(t *testing.T) { 834 + defer tests.PrintCurrentTest(t)() 835 + 836 + req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(token) 837 + resp := session.MakeRequest(t, req, http.StatusOK) 838 + 839 + var q api.QuotaInfo 840 + DecodeJSON(t, resp, &q) 841 + 842 + assert.Len(t, q.Groups, 2) 843 + assert.Len(t, q.Groups[0].Rules, 1) 844 + assert.Len(t, q.Groups[1].Rules, 1) 845 + }) 846 + }