Gitfed
bastien-mrq/gitfed / internal / store / store.go
// Package store provides bbolt-backed persistence for users, repos, ACLs,
// the remote-CA trust store and instance metadata.
package store

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"time"

	bolt "go.etcd.io/bbolt"
)

var ErrNotFound = errors.New("store: not found")

var (
	bucketUsers    = []byte("users")
	bucketRepos    = []byte("repos")
	bucketACL      = []byte("acl")
	bucketTrust    = []byte("trust")
	bucketMeta     = []byte("meta")
	bucketAudit    = []byte("audit")
	bucketAuth     = []byte("auth")     // username -> password hash, kept apart from User so it's never returned by ListUsers/GetUser
	bucketSessions = []byte("sessions") // token -> Session, for web login
	bucketRevoked  = []byte("revoked")  // revoked principals / cert keys, consulted during SSH cert auth
	bucketPins     = []byte("pins")     // principal -> bookmarked remote repos, self-service only
	bucketNotifs   = []byte("notifs")   // principal -> inbound federated notifications

	bucketMergeRequests = []byte("merge_requests") // repo -> merge requests proposing branch merges within it
	bucketMRComments    = []byte("mr_comments")    // (repo, number) -> discussion thread
)

type Store struct {
	db *bolt.DB
}

// Open opens (creating if needed) the bbolt database at path and ensures all
// buckets exist.
func Open(path string) (*Store, error) {
	db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 2 * time.Second})
	if err != nil {
		return nil, fmt.Errorf("store: open %s: %w", path, err)
	}
	err = db.Update(func(tx *bolt.Tx) error {
		for _, b := range [][]byte{bucketUsers, bucketRepos, bucketACL, bucketTrust, bucketMeta, bucketAudit, bucketAuth, bucketSessions, bucketRevoked, bucketPins, bucketNotifs, bucketMergeRequests, bucketMRComments} {
			if _, err := tx.CreateBucketIfNotExists(b); err != nil {
				return err
			}
		}
		return nil
	})
	if err != nil {
		_ = db.Close()
		return nil, fmt.Errorf("store: init buckets: %w", err)
	}
	return &Store{db: db}, nil
}

func (s *Store) Close() error {
	return s.db.Close()
}

func putJSON(tx *bolt.Tx, bucket []byte, key string, v any) error {
	data, err := json.Marshal(v)
	if err != nil {
		return err
	}
	return tx.Bucket(bucket).Put([]byte(key), data)
}

func getJSON(tx *bolt.Tx, bucket []byte, key string, v any) error {
	data := tx.Bucket(bucket).Get([]byte(key))
	if data == nil {
		return ErrNotFound
	}
	return json.Unmarshal(data, v)
}

func deleteKey(tx *bolt.Tx, bucket []byte, key string) error {
	return tx.Bucket(bucket).Delete([]byte(key))
}

func listJSON[T any](tx *bolt.Tx, bucket []byte) ([]T, error) {
	var out []T
	c := tx.Bucket(bucket).Cursor()
	for k, v := c.First(); k != nil; k, v = c.Next() {
		var item T
		if err := json.Unmarshal(v, &item); err != nil {
			return nil, err
		}
		out = append(out, item)
	}
	return out, nil
}

// listJSONPrefix is listJSON scoped to keys starting with prefix — used for
// buckets keyed "<principal>\x00<rest>" so a given user's own records (pins,
// notifications) can be listed without scanning every other user's.
func listJSONPrefix[T any](tx *bolt.Tx, bucket []byte, prefix string) ([]T, error) {
	var out []T
	c := tx.Bucket(bucket).Cursor()
	p := []byte(prefix)
	for k, v := c.Seek(p); k != nil && bytes.HasPrefix(k, p); k, v = c.Next() {
		var item T
		if err := json.Unmarshal(v, &item); err != nil {
			return nil, err
		}
		out = append(out, item)
	}
	return out, nil
}