store.gno

package users

import (
	"chain"
	"chain/runtime"
	"regexp"

	"gno.land/p/nt/avl/v0"
	"gno.land/p/nt/ufmt/v0"
)

var (
	nameStore    = avl.NewTree() // name/aliases > *UserData
	addressStore = avl.NewTree() // address > *UserData

	reAddressLookalike = regexp.MustCompile(`^g1[a-z0-9]{20,38}$`)
	reAlphanum         = regexp.MustCompile(`^[a-zA-Z0-9_]{1,64}$`)
)

const (
	RegisterUserEvent = "Registered"
	UpdateNameEvent   = "Updated"
	DeleteUserEvent   = "Deleted"
)

type UserData struct {
	addr     address
	username string // contains the latest name of a user
	deleted  bool
}

func (u UserData) Name() string {
	return u.username
}

func (u UserData) Addr() address {
	return u.addr
}

func (u UserData) IsDeleted() bool {
	return u.deleted
}

// RenderLink provides a render link to the user page on gnoweb
// `linkText` is optional
func (u UserData) RenderLink(linkText string) string {
	if linkText == "" {
		return ufmt.Sprintf("[@%s](/u/%s)", u.username, u.username)
	}

	return ufmt.Sprintf("[%s](/u/%s)", linkText, u.username)
}

// RegisterUser adds a new user to the system.
func RegisterUser(cur realm, name string, address_XXX address) error {
	// At genesis (height 0), allow any caller to register users.
	// After genesis, only whitelisted controllers can register.
	if runtime.ChainHeight() > 0 && !controllers.Has(runtime.PreviousRealm().Address()) {
		return NewErrNotWhitelisted()
	}

	// Validate name
	if err := validateName(name); err != nil {
		return err
	}

	// Validate address
	if !address_XXX.IsValid() {
		return ErrInvalidAddress
	}

	// Check if name is taken
	if nameStore.Has(name) {
		return ErrNameTaken
	}

	raw, ok := addressStore.Get(address_XXX.String())
	if ok {
		// Cannot re-register after deletion
		if raw.(*UserData).IsDeleted() {
			return ErrDeletedUser
		}

		// For a second name, use UpdateName
		return ErrAlreadyHasName
	}

	// Create UserData
	data := &UserData{
		addr:     address_XXX,
		username: name,
		deleted:  false,
	}

	// Set corresponding stores
	nameStore.Set(name, data)
	addressStore.Set(address_XXX.String(), data)

	chain.Emit(RegisterUserEvent,
		"name", name,
		"address", address_XXX.String(),
	)
	return nil
}

// UpdateName adds a name that is associated with a specific address
// All previous names are preserved and resolvable.
// The new name is the default value returned for address lookups.
func (u *UserData) UpdateName(newName string) error {
	if u == nil { // either doesnt exists or was deleted
		return ErrUserNotExistOrDeleted
	}

	// Validate caller
	if !controllers.Has(runtime.CurrentRealm().Address()) {
		return NewErrNotWhitelisted()
	}

	// Validate name
	if err := validateName(newName); err != nil {
		return err
	}

	// Check if the requested Alias is already taken
	if nameStore.Has(newName) {
		return ErrNameTaken
	}

	u.username = newName
	nameStore.Set(newName, u)

	chain.Emit(UpdateNameEvent,
		"alias", newName,
		"address", u.addr.String(),
	)
	return nil
}

// Delete marks a user and all their aliases as deleted.
func (u *UserData) Delete() error {
	if u == nil {
		return ErrUserNotExistOrDeleted
	}

	// Validate caller
	if !controllers.Has(runtime.CurrentRealm().Address()) {
		return NewErrNotWhitelisted()
	}

	u.deleted = true

	chain.Emit(DeleteUserEvent, "address", u.addr.String())
	return nil
}

// Validate validates username and address passed in
// Most of the validation is done in the controllers
// This provides more flexibility down the line
func validateName(username string) error {
	if username == "" {
		return ErrEmptyUsername
	}

	if !reAlphanum.MatchString(username) {
		return ErrInvalidUsername
	}

	// Check if the username can be decoded or looks like a valid address
	if address(username).IsValid() || reAddressLookalike.MatchString(username) {
		return ErrNameLikeAddress
	}

	return nil
}