// Package ctrl exposes a small, named set of read-only state database // operations for remote management clients. package ctrl import ( "encoding/json" "errors" "fmt" "strconv" "strings" "time" "xorm.io/xorm" "simplegit/state" ) const ( OpListRepos = "list-repos" OpListNS = "list-ns" OpGrantsForRepo = "grants-for-repo" OpGrantsForNS = "grants-for-ns" OpListGrants = "list-grants" OpGrantsForKey = "grants-for-key" OpListSSHKeys = "list-ssh-keys" OpSSHKeyByID = "ssh-key-by-id" ) var ( ErrUnknownOp = errors.New("unknown ctrl operation") ErrInvalidArgs = errors.New("invalid ctrl arguments") ) type engineProvider interface { Engine() *xorm.Engine } type Controller struct{ eng *xorm.Engine } func New(provider engineProvider) *Controller { return &Controller{eng: provider.Engine()} } type RepoRow struct { ID int64 `json:"id"` NamespaceID int64 `json:"namespace_id"` Ns string `json:"ns"` Name string `json:"name"` IsPrivate bool `json:"is_private"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } type NSRow struct { ID int64 `json:"id"` NamespaceID int64 `json:"namespace_id"` Name string `json:"name"` RepoCount int64 `json:"repo_count"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` HasPassword bool `json:"has_password"` } type GrantRow struct { AclID int64 `json:"acl_id"` CredType string `json:"cred_type"` Target string `json:"target"` Perm string `json:"perm"` BoundAt time.Time `json:"bound_at"` PatPrefix string `json:"pat_prefix"` PatName string `json:"pat_name"` SshFingerprint string `json:"ssh_fingerprint"` SshKeyType string `json:"ssh_key_type"` SshComment string `json:"ssh_comment"` CredID int64 `json:"cred_id"` TargetTypeID int `json:"target_type_id"` TargetID int64 `json:"target_id"` TargetLabel string `json:"target_label"` TargetIsRepo bool `json:"target_is_repo"` Ns string `json:"ns"` Name string `json:"name"` } type SSHKeyRow struct { ID int64 `json:"id"` Fingerprint string `json:"fingerprint"` KeyType string `json:"key_type"` Comment string `json:"comment"` LastUsedAt *time.Time `json:"last_used_at"` GrantCount int64 `json:"grant_count"` PublicKey string `json:"public_key"` } func (c *Controller) Call(op string, args ...string) (json.RawMessage, error) { var value any var err error switch op { case OpListRepos: if err = argc(op, args, 0); err == nil { value, err = c.listRepos() } case OpListNS: if err = argc(op, args, 0); err == nil { value, err = c.listNS() } case OpGrantsForRepo: if err = argc(op, args, 2); err == nil { value, err = c.grantsForRepo(args[0], args[1]) } case OpGrantsForNS: if err = argc(op, args, 1); err == nil { value, err = c.grantsForNS(args[0]) } case OpListGrants: if err = argc(op, args, 0); err == nil { value, err = c.listGrants() } case OpGrantsForKey, OpSSHKeyByID: if err = argc(op, args, 1); err != nil { break } var id int64 id, err = strconv.ParseInt(args[0], 10, 64) if err != nil { err = fmt.Errorf("%w: %s expects an integer id", ErrInvalidArgs, op) break } if op == OpGrantsForKey { value, err = c.grantsForKey(id) } else { value, err = c.sshKeyByID(id) } case OpListSSHKeys: if err = argc(op, args, 0); err == nil { value, err = c.listSSHKeys() } default: err = fmt.Errorf("%w: %q", ErrUnknownOp, op) } if err != nil { return nil, err } data, err := json.Marshal(value) if err != nil { return nil, fmt.Errorf("encode ctrl result: %w", err) } return data, nil } func argc(op string, args []string, want int) error { if len(args) != want { return fmt.Errorf("%w: %s expects %d arguments, got %d", ErrInvalidArgs, op, want, len(args)) } return nil } func (c *Controller) listRepos() ([]RepoRow, error) { var rows []struct { state.Repo `xorm:"extends"` NSName string `xorm:"ns_name"` } if err := c.eng.Table("repo").Select("repo.*, namespace.name AS ns_name").Join("INNER", "namespace", "namespace.id = repo.namespace_id").Asc("namespace.name", "repo.name").Find(&rows); err != nil { return nil, err } out := make([]RepoRow, 0, len(rows)) for i := range rows { r := &rows[i] out = append(out, RepoRow{ID: r.ID, NamespaceID: r.NamespaceID, Ns: r.NSName, Name: r.Name, IsPrivate: r.IsPrivate, Created: r.CreatedAt, Updated: r.UpdatedAt}) } return out, nil } func (c *Controller) listNS() ([]NSRow, error) { var rows []state.Namespace if err := c.eng.Asc("name").Find(&rows); err != nil { return nil, err } out := make([]NSRow, 0, len(rows)) for i := range rows { n := &rows[i] count, err := c.eng.Where("namespace_id = ?", n.ID).Count(&state.Repo{}) if err != nil { return nil, err } out = append(out, NSRow{NamespaceID: n.ID, Name: n.Name, RepoCount: count, Created: n.CreatedAt, Updated: n.UpdatedAt, HasPassword: n.PasswordHash != ""}) } return out, nil } func (c *Controller) grantsForRepo(ns, name string) ([]GrantRow, error) { name = strings.TrimSuffix(name, ".git") var repo state.Repo has, err := c.eng.Where("namespace_id IN (SELECT id FROM namespace WHERE name = ?) AND name = ?", ns, name).Get(&repo) if err != nil { return nil, err } if !has { return nil, state.ErrRepoNotFound } var rows []state.ACL if err := c.eng.Where("(target_type = ? AND target_id = ?) OR (target_type = ? AND target_id = ?)", state.TargetTypeRepo, repo.ID, state.TargetTypeNS, repo.NamespaceID).Find(&rows); err != nil { return nil, err } return c.loadGrants(rows) } func (c *Controller) grantsForNS(ns string) ([]GrantRow, error) { var n state.Namespace has, err := c.eng.Where("name = ?", ns).Get(&n) if err != nil { return nil, err } if !has { return nil, state.ErrNamespaceNotFound } var repos []state.Repo if err := c.eng.Where("namespace_id = ?", n.ID).Find(&repos); err != nil { return nil, err } var rows []state.ACL if err := c.eng.Where("target_type = ? AND target_id = ?", state.TargetTypeNS, n.ID).Find(&rows); err != nil { return nil, err } for i := range repos { var rr []state.ACL if err := c.eng.Where("target_type = ? AND target_id = ?", state.TargetTypeRepo, repos[i].ID).Find(&rr); err != nil { return nil, err } rows = append(rows, rr...) } return c.loadGrants(rows) } func (c *Controller) listGrants() ([]GrantRow, error) { var rows []state.ACL if err := c.eng.Find(&rows); err != nil { return nil, err } return c.loadGrants(rows) } func (c *Controller) grantsForKey(id int64) ([]GrantRow, error) { var rows []state.ACL if err := c.eng.Where("cred_type = ? AND cred_id = ?", state.CredTypeSSH, id).Find(&rows); err != nil { return nil, err } return c.loadGrants(rows) } func (c *Controller) listSSHKeys() ([]SSHKeyRow, error) { var keys []state.SSHKey if err := c.eng.Find(&keys); err != nil { return nil, err } out := make([]SSHKeyRow, 0, len(keys)) for i := range keys { k := &keys[i] count, err := c.eng.Where("cred_type = ? AND cred_id = ?", state.CredTypeSSH, k.ID).Count(&state.ACL{}) if err != nil { return nil, err } out = append(out, SSHKeyRow{ID: k.ID, Fingerprint: k.Fingerprint, KeyType: k.KeyType, Comment: k.Comment, LastUsedAt: k.LastUsedAt, GrantCount: count, PublicKey: k.PublicKey}) } return out, nil } func (c *Controller) sshKeyByID(id int64) (SSHKeyRow, error) { var k state.SSHKey has, err := c.eng.ID(id).Get(&k) if err != nil { return SSHKeyRow{}, err } if !has { return SSHKeyRow{}, fmt.Errorf("ssh key #%d not found", id) } return SSHKeyRow{ID: k.ID, Fingerprint: k.Fingerprint, KeyType: k.KeyType, Comment: k.Comment, LastUsedAt: k.LastUsedAt, PublicKey: k.PublicKey}, nil } func (c *Controller) loadGrants(rows []state.ACL) ([]GrantRow, error) { out := make([]GrantRow, 0, len(rows)) for i := range rows { a := &rows[i] g := GrantRow{AclID: a.ID, Perm: a.Perm, BoundAt: a.CreatedAt, Target: targetName(a.TargetType), CredType: credTypeName(a.CredType), CredID: a.CredID, TargetTypeID: a.TargetType, TargetID: a.TargetID} switch a.CredType { case int(state.CredTypePAT): var p state.PAT if has, _ := c.eng.ID(a.CredID).Get(&p); has { g.PatPrefix = p.Prefix g.PatName = p.Name } case int(state.CredTypeSSH): var k state.SSHKey if has, _ := c.eng.ID(a.CredID).Get(&k); has { g.SshFingerprint = k.Fingerprint g.SshKeyType = k.KeyType g.SshComment = k.Comment } } label, isRepo, ns, name, err := c.resolveTarget(a.TargetType, a.TargetID) if err != nil { return nil, err } g.TargetLabel = label g.TargetIsRepo = isRepo g.Ns = ns g.Name = name out = append(out, g) } return out, nil } func (c *Controller) resolveTarget(kind int, id int64) (string, bool, string, string, error) { if kind == int(state.TargetTypeRepo) { var r state.Repo has, err := c.eng.ID(id).Get(&r) if err != nil { return "", false, "", "", err } if !has { return "(deleted repo)", true, "", "", nil } var n state.Namespace has, err = c.eng.ID(r.NamespaceID).Get(&n) if err != nil { return "", false, "", "", err } if !has { return "(deleted namespace)/" + r.Name, true, "", r.Name, nil } return n.Name + "/" + r.Name, true, n.Name, r.Name, nil } var n state.Namespace has, err := c.eng.ID(id).Get(&n) if err != nil { return "", false, "", "", err } if !has { return "(deleted namespace)", false, "", "", nil } return n.Name + "/*", false, n.Name, "", nil } func credTypeName(v int) string { if v == int(state.CredTypeSSH) { return "ssh" } return "pat" } func targetName(v int) string { if v == int(state.TargetTypeNS) { return "ns" } return "repo" }