internal/control/quota.go

e3632a550366fe23b2619edba30fc58fab19b598
gitbay/internal/control/quota.go history · blame · raw

160 lines · 5094 bytes

  1package control
  2
  3import (
  4	"fmt"
  5	"io"
  6	"strconv"
  7	"sync"
  8
  9	"gitbay.org/gitbay/internal/config"
 10	"gitbay.org/gitbay/internal/gitutil"
 11	"gitbay.org/gitbay/internal/protocol"
 12	"gitbay.org/gitbay/internal/store"
 13)
 14
 15// Quotas cap what one account owns directly. The limit is the account's
 16// override when set, else the configured default; 0 is unlimited.
 17
 18// RepoLimit is the account's repository cap, 0 for none.
 19func RepoLimit(st *store.Store, cfg configLimits, userID int64) int64 {
 20	if l, err := st.UserLimits(userID); err == nil && l.Repos != nil {
 21		return *l.Repos
 22	}
 23	return int64(cfg.MaxReposPerUser)
 24}
 25
 26// ByteLimit is the account's storage cap in bytes, 0 for none.
 27func ByteLimit(st *store.Store, cfg configLimits, userID int64) int64 {
 28	if l, err := st.UserLimits(userID); err == nil && l.Bytes != nil {
 29		return *l.Bytes
 30	}
 31	return cfg.MaxBytesPerUser
 32}
 33
 34// OwnedBytes is the disk taken by the repositories a user owns directly.
 35func OwnedBytes(st *store.Store, root string, userID int64) int64 {
 36	repos, err := st.ListReposForOwner("user", userID)
 37	if err != nil {
 38		return 0
 39	}
 40	var total int64
 41	for _, r := range repos {
 42		total += gitutil.DirSize(RepoDir(root, r.OwnerName, r.Name))
 43	}
 44	return total
 45}
 46
 47// configLimits is the slice of config the quota functions read, so the
 48// sshd package can pass its Limits without importing control's Ctx.
 49type configLimits struct {
 50	MaxReposPerUser int
 51	MaxBytesPerUser int64
 52}
 53
 54// QuotaConfig is what sshd passes: the limits section of the config.
 55func QuotaConfig(cfg config.Config) configLimits {
 56	return configLimits{cfg.Limits.MaxReposPerUser, cfg.Limits.MaxBytesPerUser}
 57}
 58
 59func limitsOf(c *Ctx) configLimits {
 60	return configLimits{c.Cfg.Limits.MaxReposPerUser, c.Cfg.Limits.MaxBytesPerUser}
 61}
 62
 63// checkRepoQuota refuses a new user-owned repository past the cap.
 64// repoCreateMu serialises the quota check with the insert that follows
 65// it, so two concurrent creates cannot both pass the count (#108). One
 66// process serves the instance, so a process-wide lock is the whole story.
 67var repoCreateMu sync.Mutex
 68
 69func checkRepoQuota(c *Ctx) int {
 70	limit := RepoLimit(c.Store, limitsOf(c), c.User.ID)
 71	if limit == 0 {
 72		return -1
 73	}
 74	n, err := c.Store.OwnedRepoCount(c.User.ID)
 75	if err != nil {
 76		return c.fail(protocol.ExitFailure, "%v", err)
 77	}
 78	if n >= limit {
 79		return c.fail(protocol.ExitDenied, "you own %d of the %d repositories your account may hold; delete or transfer one, or ask an admin to raise the limit", n, limit)
 80	}
 81	return -1
 82}
 83
 84func init() {
 85	register(Command{Path: []string{"admin", "user", "limits"},
 86		Summary: "show or set an account's repository and storage caps (instance admins)",
 87		Usage:   "admin user limits <username> [--repos <n>|default] [--bytes <n>|default]",
 88		SSHOnly: true, Run: runAdminUserLimits})
 89}
 90
 91func runAdminUserLimits(c *Ctx, args []string) int {
 92	if code := requireInstanceAdmin(c); code >= 0 {
 93		return code
 94	}
 95	if len(args) < 1 {
 96		return c.fail(protocol.ExitUsage, "usage: admin user limits <username> [--repos <n>|default] [--bytes <n>|default]")
 97	}
 98	u, err := c.Store.UserByUsername(args[0])
 99	if err != nil {
100		return c.fail(protocol.ExitNotFound, "no user %q", args[0])
101	}
102	l, err := c.Store.UserLimits(u.ID)
103	if err != nil {
104		return c.fail(protocol.ExitFailure, "%v", err)
105	}
106	set := false
107	for i := 1; i < len(args); i++ {
108		if i+1 >= len(args) {
109			return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
110		}
111		v := args[i+1]
112		var target **int64
113		switch args[i] {
114		case "--repos":
115			target = &l.Repos
116		case "--bytes":
117			target = &l.Bytes
118		default:
119			return c.fail(protocol.ExitUsage, "usage: admin user limits <username> [--repos <n>|default] [--bytes <n>|default]")
120		}
121		if v == "default" {
122			*target = nil
123		} else {
124			n, err := strconv.ParseInt(v, 10, 64)
125			if err != nil || n < 0 {
126				return c.fail(protocol.ExitUsage, "%s takes a non-negative number or default", args[i])
127			}
128			*target = &n
129		}
130		set = true
131		i++
132	}
133	if set {
134		if err := c.Store.SetUserLimits(u.ID, l); err != nil {
135			return c.fail(protocol.ExitFailure, "%v", err)
136		}
137		c.Store.Audit(c.User.ID, "admin user.limits", map[string]any{"user": u.Username, "repos": l.Repos, "bytes": l.Bytes})
138	}
139	type out struct {
140		User       string `json:"user"`
141		Repos      int64  `json:"repos"` // effective cap, 0 unlimited
142		Bytes      int64  `json:"bytes"` // effective cap, 0 unlimited
143		ReposOwned int64  `json:"repos_owned"`
144		BytesOwned int64  `json:"bytes_owned"`
145		Override   bool   `json:"override"` // any per-account value set
146	}
147	d := out{User: u.Username, Repos: RepoLimit(c.Store, limitsOf(c), u.ID), Bytes: ByteLimit(c.Store, limitsOf(c), u.ID),
148		Override: l.Repos != nil || l.Bytes != nil}
149	d.ReposOwned, _ = c.Store.OwnedRepoCount(u.ID)
150	d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, u.ID)
151	return c.emit(d, func(w io.Writer) {
152		cap := func(n int64) string {
153			if n == 0 {
154				return "unlimited"
155			}
156			return strconv.FormatInt(n, 10)
157		}
158		fmt.Fprintf(w, "%s\trepos %d of %s\tbytes %d of %s\n", d.User, d.ReposOwned, cap(d.Repos), d.BytesOwned, cap(d.Bytes))
159	})
160}