docs: expand configuration docs for admin and BadgerDB
Update README to explain startup config precedence (defaults/env/admin overrides), document admin/bootstrap and feature toggles, and clarify storage locations under WARPBOX_DATA_DIR including BadgerDB metadata. Also refresh project layout to include new config and metastore packages.docs: expand configuration docs for admin and BadgerDB Update README to explain startup config precedence (defaults/env/admin overrides), document admin/bootstrap and feature toggles, and clarify storage locations under WARPBOX_DATA_DIR including BadgerDB metadata. Also refresh project layout to include new config and metastore packages.
This commit is contained in:
379
lib/metastore/store.go
Normal file
379
lib/metastore/store.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"warpbox/lib/helpers"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrDuplicate = errors.New("duplicate")
|
||||
ErrInvalid = errors.New("invalid")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *badger.DB
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
opts := badger.DefaultOptions(path).WithLogger(nil)
|
||||
db, err := badger.Open(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (store *Store) Close() error {
|
||||
if store == nil || store.db == nil {
|
||||
return nil
|
||||
}
|
||||
return store.db.Close()
|
||||
}
|
||||
|
||||
func (store *Store) SetSetting(name string, value string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: setting name cannot be empty", ErrInvalid)
|
||||
}
|
||||
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
return txn.Set(settingKey(name), []byte(value))
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) DeleteSetting(name string) error {
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
return txn.Delete(settingKey(name))
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) GetSetting(name string) (string, bool, error) {
|
||||
var value string
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get(settingKey(name))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return item.Value(func(data []byte) error {
|
||||
value = string(data)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return "", false, nil
|
||||
}
|
||||
return value, err == nil, err
|
||||
}
|
||||
|
||||
func (store *Store) ListSettings() (map[string]string, error) {
|
||||
settings := make(map[string]string)
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("setting/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
item := it.Item()
|
||||
name := strings.TrimPrefix(string(item.Key()), "setting/")
|
||||
if err := item.Value(func(data []byte) error {
|
||||
settings[name] = string(data)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return settings, err
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return "", fmt.Errorf("%w: password cannot be empty", ErrInvalid)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(hash string, password string) bool {
|
||||
if hash == "" || password == "" {
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateUserWithPassword(username string, email string, password string, tagIDs []string) (User, error) {
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user := User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
TagIDs: uniqueStrings(tagIDs),
|
||||
}
|
||||
if err := store.CreateUser(&user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateUser(user *User) error {
|
||||
if user == nil {
|
||||
return fmt.Errorf("%w: user cannot be nil", ErrInvalid)
|
||||
}
|
||||
username := strings.TrimSpace(user.Username)
|
||||
if username == "" {
|
||||
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
|
||||
}
|
||||
email := strings.TrimSpace(user.Email)
|
||||
if user.PasswordHash == "" {
|
||||
return fmt.Errorf("%w: password hash cannot be empty", ErrInvalid)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if user.ID == "" {
|
||||
id, err := helpers.RandomHexID(16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.ID = id
|
||||
}
|
||||
user.Username = username
|
||||
user.Email = email
|
||||
user.TagIDs = uniqueStrings(user.TagIDs)
|
||||
user.CreatedAt = now
|
||||
user.UpdatedAt = now
|
||||
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
if exists, err := keyExists(txn, usernameKey(username)); err != nil || exists {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: username already exists", ErrDuplicate)
|
||||
}
|
||||
if email != "" {
|
||||
if exists, err := keyExists(txn, emailKey(email)); err != nil || exists {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: email already exists", ErrDuplicate)
|
||||
}
|
||||
}
|
||||
if err := putJSON(txn, userKey(user.ID), user); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := txn.Set(usernameKey(username), []byte(user.ID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if email != "" {
|
||||
return txn.Set(emailKey(email), []byte(user.ID))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) UpdateUser(user User) error {
|
||||
if strings.TrimSpace(user.ID) == "" {
|
||||
return fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
|
||||
}
|
||||
user.Username = strings.TrimSpace(user.Username)
|
||||
user.Email = strings.TrimSpace(user.Email)
|
||||
if user.Username == "" {
|
||||
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
|
||||
}
|
||||
user.TagIDs = uniqueStrings(user.TagIDs)
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
var existing User
|
||||
if err := getJSON(txn, userKey(user.ID), &existing); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldUsername := normalizeIndex(existing.Username)
|
||||
newUsername := normalizeIndex(user.Username)
|
||||
if oldUsername != newUsername {
|
||||
if exists, err := keyExists(txn, usernameKey(user.Username)); err != nil || exists {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: username already exists", ErrDuplicate)
|
||||
}
|
||||
if err := txn.Delete(usernameKey(existing.Username)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := txn.Set(usernameKey(user.Username), []byte(user.ID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
oldEmail := normalizeIndex(existing.Email)
|
||||
newEmail := normalizeIndex(user.Email)
|
||||
if oldEmail != newEmail {
|
||||
if newEmail != "" {
|
||||
if exists, err := keyExists(txn, emailKey(user.Email)); err != nil || exists {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: email already exists", ErrDuplicate)
|
||||
}
|
||||
if err := txn.Set(emailKey(user.Email), []byte(user.ID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if oldEmail != "" {
|
||||
if err := txn.Delete(emailKey(existing.Email)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return putJSON(txn, userKey(user.ID), user)
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) GetUser(id string) (User, bool, error) {
|
||||
var user User
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
return getJSON(txn, userKey(id), &user)
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return User{}, false, nil
|
||||
}
|
||||
return user, err == nil, err
|
||||
}
|
||||
|
||||
func (store *Store) GetUserByUsername(username string) (User, bool, error) {
|
||||
return store.getUserByIndex(usernameKey(username))
|
||||
}
|
||||
|
||||
func (store *Store) GetUserByEmail(email string) (User, bool, error) {
|
||||
return store.getUserByIndex(emailKey(email))
|
||||
}
|
||||
|
||||
func (store *Store) ListUsers() ([]User, error) {
|
||||
users := []User{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("user/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var user User
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &user)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
|
||||
var id string
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get(key)
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return item.Value(func(data []byte) error {
|
||||
id = string(data)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
return store.GetUser(id)
|
||||
}
|
||||
|
||||
func putJSON(txn *badger.Txn, key []byte, value any) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return txn.Set(key, data)
|
||||
}
|
||||
|
||||
func getJSON(txn *badger.Txn, key []byte, value any) error {
|
||||
item, err := txn.Get(key)
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return item.Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, value)
|
||||
})
|
||||
}
|
||||
|
||||
func keyExists(txn *badger.Txn, key []byte) (bool, error) {
|
||||
_, err := txn.Get(key)
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func settingKey(name string) []byte {
|
||||
return []byte("setting/" + strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
func userKey(id string) []byte {
|
||||
return []byte("user/" + strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
func usernameKey(username string) []byte {
|
||||
return []byte("user_by_name/" + normalizeIndex(username))
|
||||
}
|
||||
|
||||
func emailKey(email string) []byte {
|
||||
return []byte("user_by_email/" + normalizeIndex(email))
|
||||
}
|
||||
|
||||
func normalizeIndex(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user