This commit is contained in:
Jabberwocky238
2026-07-29 06:51:43 -04:00
parent 68cda1972c
commit 319f5dd148
64 changed files with 4952 additions and 2186 deletions
+160 -102
View File
@@ -1,14 +1,3 @@
package state
import (
@@ -21,13 +10,83 @@ import (
"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, "/")
@@ -38,9 +97,6 @@ func parseRepoPath(path string) (ns, name string, err error) {
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, "..") {
@@ -60,52 +116,56 @@ func (s *LocalState) nsDir(ns string) (string, error) {
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 r Repo
has, err := s.engine.Where("namespace = ?", ns).Get(&r)
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 r.NamespaceID, nil
return n.ID, nil
}
func (s *LocalState) nextNameID() (int64, error) {
var row struct {
M int64 `xorm:"m"`
// 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 _, err := s.engine.SQL("SELECT COALESCE(MAX(name_id), 0) AS m FROM repo").Get(&row); err != nil {
return 0, fmt.Errorf("next name id: %w", err)
if has {
return n.ID, nil
}
return row.M + 1, 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) nextNamespaceID() (int64, error) {
var row struct {
M int64 `xorm:"m"`
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
}
if _, err := s.engine.SQL("SELECT COALESCE(MAX(namespace_id), 0) AS m FROM repo").Get(&row); err != nil {
return 0, fmt.Errorf("next namespace id: %w", err)
}
return row.M + 1, nil
}
func (s *LocalState) CreateRepo(ns, name string) error {
path, err := s.cfg.RepoPath(ns, name)
if err != nil {
return err
@@ -114,18 +174,7 @@ func (s *LocalState) CreateRepo(ns, name string) error {
return fmt.Errorf("create repo: already exists on disk: %s", path)
}
var nsID, nameID int64
nsID, err = s.resolveNamespaceID(ns)
if err != nil {
if !errors.Is(err, ErrNamespaceNotFound) {
return err
}
nsID, err = s.nextNamespaceID()
if err != nil {
return err
}
}
nameID, err = s.nextNameID()
nsID, err := s.ensureNamespace(ns)
if err != nil {
return err
}
@@ -133,10 +182,13 @@ func (s *LocalState) CreateRepo(ns, name string) error {
return fmt.Errorf("create repo: mkdir ns: %w", err)
}
initArgs := []string{"init", "--bare"}
// -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())
}
@@ -145,11 +197,15 @@ func (s *LocalState) CreateRepo(ns, name string) error {
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,
Namespace: ns,
NameID: nameID,
Name: name,
Name: repoName,
IsPrivate: isPrivate,
}); err != nil {
_ = os.RemoveAll(path)
return fmt.Errorf("create repo: insert: %w", err)
@@ -157,14 +213,11 @@ func (s *LocalState) CreateRepo(ns, name string) error {
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)
@@ -183,9 +236,6 @@ func (s *LocalState) DeleteRepo(ns, name string) error {
return nil
}
func (s *LocalState) MoveRepo(old, new string) error {
oldNs, oldName, err := parseRepoPath(old)
if err != nil {
@@ -216,8 +266,8 @@ func (s *LocalState) MoveRepo(old, new string) error {
if err != nil {
return err
}
upd := Repo{Namespace: newNs, Name: strings.TrimSuffix(newName, ".git")}
cols := []string{"namespace", "name"}
upd := Repo{Name: strings.TrimSuffix(newName, ".git")}
cols := []string{"name"}
if newNs != oldNs {
nsID, err := s.resolveNamespaceID(newNs)
if err != nil {
@@ -234,9 +284,6 @@ func (s *LocalState) MoveRepo(old, new string) error {
return nil
}
func (s *LocalState) MoveNS(old, new string) error {
if old == new {
return nil
@@ -249,24 +296,31 @@ func (s *LocalState) MoveNS(old, new string) error {
if err != nil {
return err
}
if _, err := os.Stat(newDir); err == nil {
return fmt.Errorf("move ns: destination exists: %s", newDir)
// 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
}
if err := os.Rename(oldDir, newDir); err != nil {
return fmt.Errorf("move ns: rename: %w", err)
}
if _, err := s.engine.Where("namespace = ?", old).Cols("namespace").Update(&Repo{Namespace: new}); err != nil {
_ = os.Rename(newDir, oldDir)
return fmt.Errorf("move ns: update: %w", err)
// 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 = ?",
@@ -295,6 +349,21 @@ func (s *LocalState) upsertACL(credType CredType, credID int64, targetType Targe
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 {
@@ -303,7 +372,6 @@ func (s *LocalState) ACLDelete(id int64) error {
return nil
}
func (s *LocalState) ensureSSHKey(key ssh.PublicKey) (int64, error) {
fp := fingerprint(key)
var sk SSHKey
@@ -325,9 +393,6 @@ func (s *LocalState) ensureSSHKey(key ssh.PublicKey) (int64, error) {
return sk.ID, nil
}
func (s *LocalState) ensurePAT(plaintext string) (int64, error) {
sum := sha256.Sum256([]byte(plaintext))
hash := hex.EncodeToString(sum[:])
@@ -350,10 +415,8 @@ func (s *LocalState) ensurePAT(plaintext string) (int64, error) {
return pat.ID, nil
}
func (s *LocalState) ACLUpsertSSHKeyOnNS(ns string, key ssh.PublicKey, perm common.Perm) (int64, error) {
nsID, err := s.resolveNamespaceID(ns)
nsID, err := s.ensureNamespace(ns)
if err != nil {
return 0, err
}
@@ -364,10 +427,8 @@ func (s *LocalState) ACLUpsertSSHKeyOnNS(ns string, key ssh.PublicKey, perm comm
return s.upsertACL(CredTypeSSH, keyID, TargetTypeNS, nsID, perm)
}
func (s *LocalState) ACLUpsertPATOnNS(ns string, pat string, perm common.Perm) (int64, error) {
nsID, err := s.resolveNamespaceID(ns)
nsID, err := s.ensureNamespace(ns)
if err != nil {
return 0, err
}
@@ -378,7 +439,6 @@ func (s *LocalState) ACLUpsertPATOnNS(ns string, pat string, perm common.Perm) (
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 {
@@ -395,8 +455,6 @@ func (s *LocalState) ACLUpsertSSHKeyOnRepo(reponame string, key ssh.PublicKey, p
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 {