Files
simplegit/state/ctrl/ctrl.go
T
2026-07-29 06:51:43 -04:00

352 lines
9.7 KiB
Go

// 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"
}