473 lines
14 KiB
Go
473 lines
14 KiB
Go
package state
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"simplegit/common"
|
|
)
|
|
|
|
func (s *LocalState) UserPasswordUpsert(ns, password string) error {
|
|
nsID, err := s.ensureNamespace(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
passwordHash := ""
|
|
if password != "" {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("hash user password: %w", err)
|
|
}
|
|
passwordHash = string(hash)
|
|
}
|
|
if _, err := s.engine.ID(nsID).Cols("password_hash").Update(&Namespace{PasswordHash: passwordHash}); err != nil {
|
|
return fmt.Errorf("update namespace password: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LocalState) UserDelete(ns string) error {
|
|
nsID, err := s.resolveNamespaceID(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result, err := s.engine.ID(nsID).Cols("password_hash").Update(&Namespace{PasswordHash: ""})
|
|
if err != nil {
|
|
return fmt.Errorf("remove namespace password: %w", err)
|
|
}
|
|
if result == 0 {
|
|
return fmt.Errorf("namespace %q not found", ns)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LocalState) CreateNS(ns string) error {
|
|
if strings.TrimSpace(ns) == "" {
|
|
return fmt.Errorf("namespace is required")
|
|
}
|
|
var existing Namespace
|
|
if has, err := s.engine.Where("name = ?", ns).Get(&existing); err != nil {
|
|
return fmt.Errorf("lookup namespace: %w", err)
|
|
} else if has {
|
|
return fmt.Errorf("namespace %q already exists", ns)
|
|
}
|
|
_, err := s.ensureNamespace(ns)
|
|
return err
|
|
}
|
|
|
|
func (s *LocalState) DeleteNS(ns string) error {
|
|
nsID, err := s.resolveNamespaceID(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
count, err := s.engine.Where("namespace_id = ?", nsID).Count(&Repo{})
|
|
if err != nil {
|
|
return fmt.Errorf("count namespace repos: %w", err)
|
|
}
|
|
if count != 0 {
|
|
return fmt.Errorf("namespace %q still has %d repo(s)", ns, count)
|
|
}
|
|
if _, err := s.engine.Where("target_type = ? AND target_id = ?", TargetTypeNS, nsID).Delete(&ACL{}); err != nil {
|
|
return fmt.Errorf("delete namespace grants: %w", err)
|
|
}
|
|
if _, err := s.engine.ID(nsID).Delete(&Namespace{}); err != nil {
|
|
return fmt.Errorf("delete namespace: %w", err)
|
|
}
|
|
if dir, err := s.nsDir(ns); err == nil {
|
|
_ = os.Remove(dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseRepoPath(path string) (ns, name string, err error) {
|
|
path = strings.TrimPrefix(path, "/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
if i := strings.LastIndex(path, "/"); i >= 0 {
|
|
return path[:i], path[i+1:], nil
|
|
}
|
|
return "", "", fmt.Errorf("repo path %q has no namespace segment", path)
|
|
}
|
|
|
|
func (s *LocalState) nsDir(ns string) (string, error) {
|
|
ns = strings.TrimPrefix(ns, "/")
|
|
if ns == "" || strings.Contains(ns, "..") {
|
|
return "", fmt.Errorf("invalid namespace %q", ns)
|
|
}
|
|
abs, err := filepath.Abs(filepath.Join(s.cfg.RepoRoot(), ns))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rootAbs, err := filepath.Abs(s.cfg.RepoRoot())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if abs == rootAbs || !strings.HasPrefix(abs, rootAbs+string(os.PathSeparator)) {
|
|
return "", fmt.Errorf("namespace path escapes root")
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
// resolveNamespaceID returns the stable ID of an EXISTING namespace (by name),
|
|
// looking up the Namespace table. Returns ErrNamespaceNotFound if no such
|
|
// namespace exists; callers that want get-or-create must use ensureNamespace.
|
|
func (s *LocalState) resolveNamespaceID(ns string) (int64, error) {
|
|
var n Namespace
|
|
has, err := s.engine.Where("name = ?", ns).Get(&n)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("lookup namespace: %w", err)
|
|
}
|
|
if !has {
|
|
return 0, fmt.Errorf("namespace %q: %w", ns, ErrNamespaceNotFound)
|
|
}
|
|
return n.ID, nil
|
|
}
|
|
|
|
// ensureNamespace returns the stable ID of ns, creating the Namespace row
|
|
// (allocating a fresh autoincrement ID) if it does not yet exist. This lets a
|
|
// key/PAT be granted on a namespace BEFORE any repo is created there - the
|
|
// namespace exists independently of repos now. The ID is stable across renames
|
|
// (MoveNS updates Namespace.Name, not ID), which is what keeps ACL grants valid
|
|
// when a user renames.
|
|
func (s *LocalState) ensureNamespace(ns string) (int64, error) {
|
|
var n Namespace
|
|
has, err := s.engine.Where("name = ?", ns).Get(&n)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("lookup namespace: %w", err)
|
|
}
|
|
if has {
|
|
return n.ID, nil
|
|
}
|
|
n = Namespace{Name: ns}
|
|
if _, err := s.engine.Insert(&n); err != nil {
|
|
// Race: another caller may have inserted concurrently - re-fetch.
|
|
has, err = s.engine.Where("name = ?", ns).Get(&n)
|
|
if err != nil || !has {
|
|
return 0, fmt.Errorf("create namespace %q: %w", ns, err)
|
|
}
|
|
}
|
|
return n.ID, nil
|
|
}
|
|
|
|
func (s *LocalState) UpsertRepo(ns, name string, isPrivate bool) error {
|
|
if repo, err := s.findRepo(ns, name); err == nil {
|
|
if _, err := s.engine.ID(repo.ID).Cols("is_private").Update(&Repo{IsPrivate: isPrivate}); err != nil {
|
|
return fmt.Errorf("update repo: %w", err)
|
|
}
|
|
return nil
|
|
} else if !errors.Is(err, ErrRepoNotFound) {
|
|
return err
|
|
}
|
|
path, err := s.cfg.RepoPath(ns, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
return fmt.Errorf("create repo: already exists on disk: %s", path)
|
|
}
|
|
|
|
nsID, err := s.ensureNamespace(ns)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("create repo: mkdir ns: %w", err)
|
|
}
|
|
|
|
// -b main pins the bare repo's initial HEAD to refs/heads/main. Without it,
|
|
// git uses its compiled-in default (init.defaultBranch, "master" when unset),
|
|
// so a repo whose first (and only) push is "main" would be left with HEAD
|
|
// dangling at refs/heads/master: `git clone` (C git) warns but recovers,
|
|
// while go-git (used by act's actions/checkout) fails hard with "reference
|
|
// not found". The whole platform defaults new repos to main, so pin it here.
|
|
initArgs := []string{"init", "--bare", "-b", "main"}
|
|
if fi, err := os.Stat(s.cfg.TemplateDir()); err == nil && fi.IsDir() {
|
|
initArgs = append(initArgs, "--template="+s.cfg.TemplateDir())
|
|
}
|
|
initArgs = append(initArgs, path)
|
|
if out, err := exec.Command("git", initArgs...).CombinedOutput(); err != nil {
|
|
return fmt.Errorf("create repo: git init: %w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
// Store the name WITHOUT the .git suffix: findRepo (and MoveRepo) trim .git
|
|
// when querying, so a stored "foo.git" would never match a lookup for "foo".
|
|
// Callers may pass either form (the console passes owner/name.git); normalize
|
|
// here so the access checks (AccessByPAT / AccessBySSHKey) find the record.
|
|
repoName := strings.TrimSuffix(name, ".git")
|
|
if _, err := s.engine.Insert(&Repo{
|
|
NamespaceID: nsID,
|
|
Name: repoName,
|
|
IsPrivate: isPrivate,
|
|
}); err != nil {
|
|
_ = os.RemoveAll(path)
|
|
return fmt.Errorf("create repo: insert: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LocalState) ExistRepo(ns, name string) error {
|
|
_, err := s.findRepo(ns, name)
|
|
return err
|
|
}
|
|
|
|
func (s *LocalState) DeleteRepo(ns, name string) error {
|
|
path, _ := s.cfg.RepoPath(ns, name)
|
|
r, err := s.findRepo(ns, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.engine.Where("target_type = ? AND target_id = ?", TargetTypeRepo, r.ID).Unscoped().Delete(&ACL{}); err != nil {
|
|
return fmt.Errorf("delete repo acls: %w", err)
|
|
}
|
|
if _, err := s.engine.ID(r.ID).Unscoped().Delete(&Repo{}); err != nil {
|
|
return fmt.Errorf("delete repo: %w", err)
|
|
}
|
|
if path != "" {
|
|
_ = os.RemoveAll(path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LocalState) MoveRepo(old, new string) error {
|
|
oldNs, oldName, err := parseRepoPath(old)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newNs, newName, err := parseRepoPath(new)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
oldPath, err := s.cfg.RepoPath(oldNs, oldName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newPath, err := s.cfg.RepoPath(newNs, newName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(newPath); err == nil {
|
|
return fmt.Errorf("move repo: destination exists: %s", newPath)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
|
return fmt.Errorf("move repo: mkdir ns: %w", err)
|
|
}
|
|
if err := os.Rename(oldPath, newPath); err != nil {
|
|
return fmt.Errorf("move repo: rename: %w", err)
|
|
}
|
|
r, err := s.findRepo(oldNs, oldName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
upd := Repo{Name: strings.TrimSuffix(newName, ".git")}
|
|
cols := []string{"name"}
|
|
if newNs != oldNs {
|
|
nsID, err := s.resolveNamespaceID(newNs)
|
|
if err != nil {
|
|
_ = os.Rename(newPath, oldPath)
|
|
return err
|
|
}
|
|
upd.NamespaceID = nsID
|
|
cols = append(cols, "namespace_id")
|
|
}
|
|
if _, err := s.engine.ID(r.ID).Cols(cols...).Update(&upd); err != nil {
|
|
_ = os.Rename(newPath, oldPath)
|
|
return fmt.Errorf("move repo: update: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LocalState) MoveNS(old, new string) error {
|
|
if old == new {
|
|
return nil
|
|
}
|
|
oldDir, err := s.nsDir(old)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newDir, err := s.nsDir(new)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Rename the namespace directory (bare repos) if it exists. A namespace
|
|
// can exist (a key/PAT was granted on it) with no repos yet, so oldDir may
|
|
// be absent - skip the dir rename in that case.
|
|
dirMoved := false
|
|
if _, err := os.Stat(oldDir); err == nil {
|
|
if _, err := os.Stat(newDir); err == nil {
|
|
return fmt.Errorf("move ns: destination exists: %s", newDir)
|
|
}
|
|
if err := os.Rename(oldDir, newDir); err != nil {
|
|
return fmt.Errorf("move ns: rename: %w", err)
|
|
}
|
|
dirMoved = true
|
|
}
|
|
// Rename the namespace entity. Only the Name moves - the stable ID is
|
|
// preserved, so ACL grants keyed by Namespace.ID stay valid under the new
|
|
// name (this is what lets a renamed user keep access to their repos).
|
|
if _, err := s.engine.Where("name = ?", old).Cols("name").Update(&Namespace{Name: new}); err != nil {
|
|
if dirMoved {
|
|
_ = os.Rename(newDir, oldDir)
|
|
}
|
|
return fmt.Errorf("move ns: update namespace: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LocalState) upsertACL(credType CredType, credID int64, targetType TargetType, targetID int64, perm common.Perm) (int64, error) {
|
|
var acl ACL
|
|
has, err := s.engine.Where("cred_type = ? AND cred_id = ? AND target_type = ? AND target_id = ?",
|
|
credType, credID, targetType, targetID).Get(&acl)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("lookup acl: %w", err)
|
|
}
|
|
if has {
|
|
if acl.Perm != string(perm) {
|
|
if _, err := s.engine.ID(acl.ID).Cols("perm").Update(&ACL{Perm: string(perm)}); err != nil {
|
|
return 0, fmt.Errorf("update acl: %w", err)
|
|
}
|
|
}
|
|
return acl.ID, nil
|
|
}
|
|
acl = ACL{
|
|
CredType: int(credType),
|
|
CredID: credID,
|
|
TargetType: int(targetType),
|
|
TargetID: targetID,
|
|
Perm: string(perm),
|
|
}
|
|
if _, err := s.engine.Insert(&acl); err != nil {
|
|
return 0, fmt.Errorf("insert acl: %w", err)
|
|
}
|
|
return acl.ID, nil
|
|
}
|
|
|
|
func (s *LocalState) ACLSetPerm(id int64, perm common.Perm) error {
|
|
switch perm {
|
|
case common.PermRead, common.PermWrite, common.PermAdmin:
|
|
default:
|
|
return fmt.Errorf("invalid permission %q", perm)
|
|
}
|
|
result, err := s.engine.ID(id).Cols("perm").Update(&ACL{Perm: string(perm)})
|
|
if err != nil {
|
|
return fmt.Errorf("update acl permission: %w", err)
|
|
}
|
|
if result == 0 {
|
|
return fmt.Errorf("acl %d not found", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LocalState) ACLDelete(id int64) error {
|
|
if _, err := s.engine.ID(id).Unscoped().Delete(&ACL{}); err != nil {
|
|
return fmt.Errorf("delete acl: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LocalState) ensureSSHKey(key ssh.PublicKey) (int64, error) {
|
|
fp := fingerprint(key)
|
|
var sk SSHKey
|
|
has, err := s.engine.Where("fingerprint = ?", fp).Get(&sk)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("lookup ssh key: %w", err)
|
|
}
|
|
if has {
|
|
return sk.ID, nil
|
|
}
|
|
sk = SSHKey{
|
|
PublicKey: string(key.Marshal()),
|
|
Fingerprint: fp,
|
|
KeyType: key.Type(),
|
|
}
|
|
if _, err := s.engine.Insert(&sk); err != nil {
|
|
return 0, fmt.Errorf("insert ssh key: %w", err)
|
|
}
|
|
return sk.ID, nil
|
|
}
|
|
|
|
func (s *LocalState) ensurePAT(plaintext string) (int64, error) {
|
|
sum := sha256.Sum256([]byte(plaintext))
|
|
hash := hex.EncodeToString(sum[:])
|
|
var pat PAT
|
|
has, err := s.engine.Where("token_hash = ?", hash).Get(&pat)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("lookup pat: %w", err)
|
|
}
|
|
if has {
|
|
return pat.ID, nil
|
|
}
|
|
prefix := plaintext
|
|
if n := len(patPrefix) + 8; len(prefix) > n {
|
|
prefix = prefix[:n]
|
|
}
|
|
pat = PAT{TokenHash: hash, Prefix: prefix}
|
|
if _, err := s.engine.Insert(&pat); err != nil {
|
|
return 0, fmt.Errorf("insert pat: %w", err)
|
|
}
|
|
return pat.ID, nil
|
|
}
|
|
|
|
func (s *LocalState) ACLUpsertSSHKeyOnNS(ns string, key ssh.PublicKey, perm common.Perm) (int64, error) {
|
|
nsID, err := s.ensureNamespace(ns)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
keyID, err := s.ensureSSHKey(key)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.upsertACL(CredTypeSSH, keyID, TargetTypeNS, nsID, perm)
|
|
}
|
|
|
|
func (s *LocalState) ACLUpsertPATOnNS(ns string, pat string, perm common.Perm) (int64, error) {
|
|
nsID, err := s.ensureNamespace(ns)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
patID, err := s.ensurePAT(pat)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.upsertACL(CredTypePAT, patID, TargetTypeNS, nsID, perm)
|
|
}
|
|
|
|
func (s *LocalState) ACLUpsertSSHKeyOnRepo(reponame string, key ssh.PublicKey, perm common.Perm) (int64, error) {
|
|
ns, name, err := parseRepoPath(reponame)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
r, err := s.findRepo(ns, name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
keyID, err := s.ensureSSHKey(key)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.upsertACL(CredTypeSSH, keyID, TargetTypeRepo, r.ID, perm)
|
|
}
|
|
|
|
func (s *LocalState) ACLUpsertPATOnRepo(reponame string, pat string, perm common.Perm) (int64, error) {
|
|
ns, name, err := parseRepoPath(reponame)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
r, err := s.findRepo(ns, name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
patID, err := s.ensurePAT(pat)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.upsertACL(CredTypePAT, patID, TargetTypeRepo, r.ID, perm)
|
|
}
|