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 }