package control import ( "errors" "fmt" "io" "os" "golang.org/x/crypto/ssh" "gitbay.org/gitbay/internal/gitutil" "gitbay.org/gitbay/internal/lfs" "gitbay.org/gitbay/internal/mail" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) // The account, email, invite and stats commands gitbayd admin used to // implement on its own. They live here so the host binary and an admin // session run the same code; gitbayd admin dispatches into these. func init() { register(Command{Path: []string{"admin", "user", "create"}, Summary: "create an account, optionally with a key and a verified address (instance admins)", Usage: "admin user create [--admin] [--email
[--verified]] [--key -] < key.pub", Flags: []Flag{ {"--admin", "", "make the account an instance admin", ""}, {"--email", "
", "an address to add", ""}, {"--verified", "", "mark that address verified", ""}, {"--key", "-", "read a public key from stdin", ""}, }, Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"}, ReadsStdin: true, MintsCredential: true, Run: runAdminUserCreate}) register(Command{Path: []string{"admin", "user", "disable"}, Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", Usage: "admin user disable ", Examples: []string{"admin user disable alice"}, Run: runAdminUserDisable}) register(Command{Path: []string{"admin", "user", "enable"}, Summary: "restore a suspended account", Usage: "admin user enable ", Examples: []string{"admin user enable alice"}, Run: runAdminUserEnable}) register(Command{Path: []string{"admin", "user", "delete"}, Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)", Usage: "admin user delete --yes", Flags: []Flag{ {"--yes", "", "confirm the permanent delete", ""}, }, Examples: []string{"admin user delete alice --yes"}, Run: runAdminUserDelete}) register(Command{Path: []string{"admin", "email", "verify"}, Summary: "mark an address verified by admin assertion", Usage: "admin email verify
", Examples: []string{"admin email verify alice alice@example.org"}, MintsCredential: true, Run: runAdminEmailVerify}) register(Command{Path: []string{"admin", "invite"}, Summary: "issue a registration invite and mail its code", Usage: "admin invite --email
", Flags: []Flag{ {"--email", "
", "who the invite is for", ""}, }, Examples: []string{"admin invite --email alice@example.org"}, MintsCredential: true, Run: runAdminInvite}) register(Command{Path: []string{"admin", "stats"}, Summary: "instance statistics: counts and per-repository disk usage", Usage: "admin stats", Examples: []string{"admin stats"}, ReadOnly: true, Run: runAdminStats}) } func runAdminUserCreate(c *Ctx, args []string) int { if code := requireInstanceAdmin(c); code >= 0 { return code } f, err := parseFlags(args, flagSpec{Values: []string{"--email", "--key"}, Bools: []string{"--admin", "--verified"}, MaxPos: 1, Usage: c.Cmd.Usage}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } username, email := f.pos(0), f.Value("--email") isAdmin, verified, withKey := f.Has("--admin"), f.Has("--verified"), f.Has("--key") if withKey && f.Value("--key") != "-" { return c.fail(protocol.ExitUsage, "--key only supports - (the public key on stdin)") } if username == "" || username[0] == '-' { return c.usage() } if username == "" || (verified && email == "") { return c.usage() } if err := policy.ValidateOwnerName(username); err != nil { return c.failInput(err) } // Parse the key before creating anything, so a bad key leaves no // half-made account behind. var pub ssh.PublicKey var comment string if withKey { raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10)) if err != nil { return c.fail(protocol.ExitFailure, "reading key: %v", err) } if pub, comment, _, _, err = ssh.ParseAuthorizedKey(raw); err != nil { return c.fail(protocol.ExitUsage, "not a public key in authorized_keys format: %v", err) } } uid, err := c.Store.CreateUser(username, isAdmin) if err != nil { return c.failErr(err) } if email != "" { by := "" if verified { by = "admin" } if err := c.Store.AddEmail(uid, email, by, true); err != nil { return c.failErr(err) } } fp := "" if pub != nil { fp = ssh.FingerprintSHA256(pub) label, _ := keyLabel(comment) if err := c.Store.AddSSHKeyFrom(uid, fp, pub.Type(), pub.Marshal(), "full", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil { return c.failErr(err) } } c.Store.Audit(c.User.ID, "admin user.created", map[string]any{"user": username}) type out struct { User string `json:"user"` Admin bool `json:"admin,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` } return c.emit(out{username, isAdmin, fp}, func(w io.Writer) { if fp != "" { fmt.Fprintln(w, "key", fp) } fmt.Fprintln(w, "created user", username) }) } // adminUserArg resolves the single username argument of an admin command. func adminUserArg(c *Ctx, args []string, usage string) (store.User, int) { if code := requireInstanceAdmin(c); code >= 0 { return store.User{}, code } if len(args) != 1 { return store.User{}, c.usage() } u, err := c.Store.UserByUsername(args[0]) if errors.Is(err, store.ErrNotFound) { return u, c.fail(protocol.ExitNotFound, "no user %q", args[0]) } else if err != nil { return u, c.fail(protocol.ExitFailure, "%v", err) } return u, -1 } func runAdminUserDisable(c *Ctx, args []string) int { u, code := adminUserArg(c, args, "admin user disable ") if code >= 0 { return code } if err := c.Store.SetUserDisabled(u.ID, true); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.Audit(c.User.ID, "admin user.disabled", map[string]any{"user": u.Username}) return c.emit(map[string]any{"user": u.Username, "disabled": true}, func(w io.Writer) { fmt.Fprintf(w, "disabled %s: SSH, web sessions, and API tokens are refused; nothing was deleted\n", u.Username) }) } func runAdminUserEnable(c *Ctx, args []string) int { u, code := adminUserArg(c, args, "admin user enable ") if code >= 0 { return code } if err := c.Store.SetUserDisabled(u.ID, false); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.Audit(c.User.ID, "admin user.enabled", map[string]any{"user": u.Username}) return c.emit(map[string]any{"user": u.Username, "disabled": false}, func(w io.Writer) { fmt.Fprintf(w, "enabled %s\n", u.Username) }) } func runAdminUserDelete(c *Ctx, args []string) int { var rest []string var yes bool for _, a := range args { if a == "--yes" { yes = true } else { rest = append(rest, a) } } u, code := adminUserArg(c, rest, "admin user delete --yes") if code >= 0 { return code } if !yes { return c.fail(protocol.ExitUsage, "deletion is permanent; pass --yes") } if u.ID == c.User.ID { return c.fail(protocol.ExitUsage, "that is your own account") } if err := c.Store.DeleteUser(u.ID); err != nil { return c.failErr(err) } c.Store.Audit(c.User.ID, "admin user.deleted", map[string]any{"user": u.Username}) return c.emit(map[string]string{"deleted": u.Username}, func(w io.Writer) { fmt.Fprintf(w, "deleted %s\n", u.Username) }) } func runAdminEmailVerify(c *Ctx, args []string) int { if code := requireInstanceAdmin(c); code >= 0 { return code } if len(args) != 2 { return c.usage() } u, err := c.Store.UserByUsername(args[0]) if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "no user %q", args[0]) } else if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if err := c.Store.VerifyEmail(u.ID, args[1], "admin"); err != nil { c.Store.Audit(c.User.ID, "admin email.verify_failed", map[string]any{"user": args[0], "email": args[1]}) return c.fail(protocol.ExitNotFound, "no address %s on user %s", args[1], args[0]) } c.Store.Audit(c.User.ID, "admin email.verified", map[string]any{"user": args[0], "email": args[1]}) return c.emit(map[string]string{"user": args[0], "verified": args[1]}, func(w io.Writer) { fmt.Fprintln(w, "verified", args[1]) }) } func runAdminInvite(c *Ctx, args []string) int { if code := requireInstanceAdmin(c); code >= 0 { return code } email := "" if len(args) == 2 && args[0] == "--email" { email = args[1] } if email == "" { return c.usage() } if used, err := c.Store.EmailInUse(email); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } else if used { return c.fail(protocol.ExitUsage, "%s already belongs to an account; invites are for new users", email) } code, hash, err := store.NewToken() if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if err := c.Store.CreateInvite(hash, email); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } host := siteHost(c.Cfg) body := fmt.Sprintf( "You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+ " ssh git@%s register --username --invite %s\n\n"+ "The invite is single-use and tied to this address.\n", host, host, code) type out struct { Email string `json:"email"` Mailed bool `json:"mailed"` Code string `json:"code,omitempty"` // only when it could not be mailed } if c.Cfg.Mail.SMTPHost != "" { if err := mail.Send(c.Cfg, email, "your invite to "+host, body); err != nil { return c.fail(protocol.ExitFailure, "invite stored but mail failed: %v (code: %s)", err, code) } c.Store.Audit(c.User.ID, "admin invite.issued", map[string]any{"email": email}) return c.emit(out{Email: email, Mailed: true}, func(w io.Writer) { fmt.Fprintf(w, "invite emailed to %s\n", email) }) } return c.emit(out{Email: email, Code: code}, func(w io.Writer) { fmt.Fprintf(w, "invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code) }) } func runAdminStats(c *Ctx, args []string) int { if code := requireInstanceAdmin(c); code >= 0 { return code } if len(args) != 0 { return c.usage() } counts, err := c.Store.InstanceCounts() if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } repos, err := c.Store.ListAllRepos() if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } type repoDisk struct { Path string `json:"path"` Bytes int64 `json:"bytes"` } type out struct { Counts store.Counts `json:"counts"` DBBytes int64 `json:"db_bytes"` RepoBytes int64 `json:"repo_bytes"` LFSBytes int64 `json:"lfs_bytes"` Repos []repoDisk `json:"repos"` } d := out{Counts: counts, Repos: []repoDisk{}} d.LFSBytes = lfs.LocalStore{Root: lfs.RootFor(c.Cfg.LFS.Root, c.Cfg.Server.Root)}.Size() for _, r := range repos { b := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) d.Repos = append(d.Repos, repoDisk{r.Path(), b}) d.RepoBytes += b } if fi, err := os.Stat(c.Cfg.Server.Root + "/gitbay.db"); err == nil { d.DBBytes = fi.Size() } return c.emit(d, func(w io.Writer) { v := c.view(w) v.fields( "users", fmt.Sprintf("%d", counts.Users), "orgs", fmt.Sprintf("%d", counts.Orgs), "repos", fmt.Sprintf("%d", counts.Repos), "issues", fmt.Sprintf("%d (%d open)", counts.Issues, counts.OpenIssues), "MRs", fmt.Sprintf("%d (%d open)", counts.MRs, counts.OpenMRs), "database", humanBytes(d.DBBytes), "repositories", humanBytes(d.RepoBytes), "lfs", humanBytes(d.LFSBytes), ) if len(d.Repos) > 0 { v.section("repos") tb := c.table(w, "PATH", "BYTES") for _, r := range d.Repos { tb.row(cRef(r.Path), cText(humanBytes(r.Bytes))) } tb.flush() } }) } func humanBytes(b int64) string { switch { case b >= 1<<30: return fmt.Sprintf("%.1f GiB", float64(b)/(1<<30)) case b >= 1<<20: return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20)) case b >= 1<<10: return fmt.Sprintf("%.1f KiB", float64(b)/(1<<10)) default: return fmt.Sprintf("%d B", b) } }