linstrom/storage/user.go
mStar 529d106351 Better tracing
Not done yet, still need to add them to just about every other function
2024-11-06 16:57:44 +01:00

484 lines
17 KiB
Go

package storage
import (
"crypto/ed25519"
"crypto/rand"
"errors"
"fmt"
"strings"
"time"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/google/uuid"
"github.com/mstarongithub/passkey"
"github.com/rs/zerolog/log"
"gitlab.com/mstarongitlab/linstrom/ap"
"gitlab.com/mstarongitlab/linstrom/util"
"gorm.io/gorm"
)
// Database representation of a user account
// This can be a bot, remote or not
// If remote, this is used for caching the account
type Account struct {
ID string `gorm:"primarykey"` // ID is a uuid for this account
// Username of the user (eg "max" if the full username is @max@example.com)
// Assume unchangable (once set by a user) to be kind to other implementations
// Would be an easy avenue to fuck with them though
Username string
CreatedAt time.Time // When this entry was created. Automatically set by gorm
// When this account was last updated. Will also be used for refreshing remote accounts. Automatically set by gorm
UpdatedAt time.Time
// When this entry was deleted (for soft deletions)
// Soft delete means that this entry still exists in the db, but gorm won't include it anymore unless specifically told to
// If not null, this entry is marked as deleted
DeletedAt gorm.DeletedAt `gorm:"index"`
// Server RemoteServer // `gorm:"foreignKey:ServerId;references:ID"` // The server this user is from
ServerId uint // Id of the server this user is from, needed for including RemoteServer
DisplayName string // The display name of the user. Can be different from the handle
CustomFields []uint `gorm:"serializer:json"` // IDs to the custom fields a user has
Description string // The description of a user account
Tags []string `gorm:"serializer:json"` // Hashtags
IsBot bool // Whether to mark this account as a script controlled one
Follows []string `gorm:"serializer:json"` // List of account ids this account follows
Followers []string `gorm:"serializer:json"` // List of account ids that follow this account
Icon string // ID of a media file used as icon
Background *string // ID of a media file used as background image
Banner *string // ID of a media file used as banner
Indexable bool // Whether this account can be found by crawlers
PublicKey []byte // The public key of the account
// Whether this account restricts following
// If true, the owner must approve of a follow request first
RestrictedFollow bool
// List of things the owner identifies as
// Example [cat human robot] means that the owner probably identifies as
// a cyborg-catgirl/boy/human or a cathuman shaped robot, refer to Gender for pronouns
IdentifiesAs []Being `gorm:"serializer:json"`
// List of pronouns the owner identifies with
// An unordered list since the owner can freely set it
// Examples: [she her], [it they its them] or, if you want to go fancy, [this is super serious]
Gender []string `gorm:"serializer:json"`
// The roles assocciated with an account. Values are the names of the roles
Roles []string `gorm:"serializer:json"`
Location *string
Birthday *time.Time
// --- And internal account stuff ---
// Still public fields since they wouldn't be able to be stored in the db otherwise
PrivateKey []byte // The private key of the account. Nil if remote user
WebAuthnId []byte // The unique and random ID of this account used for passkey authentication
// Whether the account got verified and is allowed to be active
// For local accounts being active means being allowed to login and perform interactions
// For remote users, if an account is not verified, any interactions it sends are discarded
Verified bool
// TODO: Turn this into a map to give passkeys names.
// Needed for supporting a decent passkey management interface.
// Or check if webauthn.Credential has sufficiently easy to identify data
// to use instead of a string mapping
PasskeyCredentials []webauthn.Credential `gorm:"serializer:json"` // Webauthn credentials data
// Has a RemoteAccountLinks included if remote user
RemoteLinks *RemoteAccountLinks
}
// Contains static and cached info about a remote account, mostly links
type RemoteAccountLinks struct {
// ---- Section: gorm
// Sets this struct up as a value that an Account may have
gorm.Model
AccountID string
// Just about every link here is optional to accomodate for servers with only minimal accounts
// Minimal being handle, ap link and inbox
ApLink string
ViewLink *string
FollowersLink *string
FollowingLink *string
InboxLink string
OutboxLink *string
FeaturedLink *string
FeaturedTagsLink *string
}
// Find an account in the db using a given full handle (@max@example.com)
// Returns an account and nil if an account is found, otherwise nil and the error
func (s *Storage) FindAccountByFullHandle(handle string) (*Account, error) {
defer util.Untrace(util.Trace(&log.Logger))
log.Debug().Str("account-handle", handle).Msg("Looking for account by handle")
log.Debug().Str("account-handle", handle).Msg("Checking if there's a cache hit")
// Try and find the account in cache first
cacheAccId, err := s.cacheHandleToAccUid(handle)
if err == nil {
log.Info().Str("account-handle", handle).Msg("Hit account handle in cache")
// Then always load via id since unique key access should be faster than string matching
return s.FindAccountById(*cacheAccId)
} else {
if !errors.Is(err, errCacheNotFound) {
log.Error().Err(err).Str("account-handle", handle).Msg("Problem while checking cache for account")
return nil, err
}
}
// Failed to find in cache, go the slow route of hitting the db
log.Debug().Str("account-handle", handle).Msg("Didn't hit account in cache, going to db")
name, server, err := ap.SplitFullHandle(handle)
if err != nil {
log.Warn().Err(err).Str("account-handle", handle).Msg("Failed to split up account handle")
return nil, err
}
acc := Account{}
res := s.db.Where("name = ?", name).Where("server = ?", server).First(&acc)
if res.Error != nil {
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
log.Info().Str("account-handle", handle).Msg("Account with handle not found")
} else {
log.Error().Err(err).Str("account-handle", handle).Msg("Failed to get account with handle")
}
return nil, res.Error
}
log.Info().Str("account-handle", handle).Msg("Found account, also inserting into cache")
if err = s.cache.Set(cacheUserIdToAccPrefix+acc.ID, &acc); err != nil {
log.Warn().
Err(err).
Str("account-handle", handle).
Msg("Found account but failed to insert into cache")
}
if err = s.cache.Set(cacheUserHandleToIdPrefix+strings.TrimLeft(handle, "@"), acc.ID); err != nil {
log.Warn().
Err(err).
Str("account-handle", handle).
Msg("Failed to store handle to id in cache")
}
return &acc, nil
}
// Find an account given a specific ID
func (s *Storage) FindAccountById(id string) (*Account, error) {
defer util.Untrace(util.Trace(&log.Logger))
log.Debug().Str("account-id", id).Msg("Looking for account by id")
log.Debug().Str("account-id", id).Msg("First trying to hit cache")
acc, err := s.cacheAccIdToData(id)
if err == nil {
log.Info().Str("account-id", id).Msg("Found account in cache")
return acc, nil
} else if !errors.Is(err, errCacheNotFound) {
log.Error().Err(err).Str("account-id", id).Msg("Error while looking for account in cache")
return nil, err
}
log.Debug().Str("account-id", id).Msg("Didn't hit account in cache, checking db")
acc = &Account{ID: id}
res := s.db.First(acc)
if res.Error != nil {
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
log.Warn().Str("account-id", id).Msg("Account not found")
return nil, ErrEntryNotFound
} else {
log.Error().Err(res.Error).Str("account-id", id).Msg("Failed to look for account")
return nil, res.Error
}
}
log.Info().Str("account-id", id).Msg("Found account in db, also adding to cache")
if err = s.cache.Set(cacheUserIdToAccPrefix+id, acc); err != nil {
log.Warn().Err(err).Str("account-id", id).Msg("Failed to add account to cache")
}
return acc, nil
}
func (s *Storage) FindLocalAccountByUsername(username string) (*Account, error) {
defer util.Untrace(util.Trace(&log.Logger))
log.Debug().Str("account-username", username).Msg("Looking for local account")
log.Debug().Str("account-username", username).Msg("Checking cache first")
// Try and find the account in cache first
cacheAccId, err := s.cacheLocalUsernameToAccUid(username)
if err == nil {
log.Info().Str("account-username", username).Msg("Hit account handle in cache")
// Then always load via id since unique key access should be faster than string matching
return s.FindAccountById(*cacheAccId)
} else {
if err != errCacheNotFound {
log.Error().Err(err).Str("account-username", username).Msg("Problem while checking cache for account")
return nil, err
}
}
// Failed to find in cache, go the slow route of hitting the db
log.Debug().Str("account-username", username).Msg("Didn't hit account in cache, going to db")
acc := Account{}
res := s.db.Where("username = ?", username).
Where("server_id = ?", serverSelf.ID).
First(&acc)
if res.Error != nil {
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
log.Info().
Str("account-username", username).
Msg("Local account with username not found")
} else {
log.Error().Err(err).Str("account-username", username).Msg("Failed to get local account with username")
}
return nil, ErrEntryNotFound
}
log.Info().Str("account-username", username).Msg("Found account, also inserting into cache")
if err = s.cache.Set(cacheUserIdToAccPrefix+acc.ID, &acc); err != nil {
log.Warn().
Err(err).
Str("account-username", username).
Msg("Found account but failed to insert into cache")
}
if err = s.cache.Set(cacheLocalUsernameToIdPrefix+username, acc.ID); err != nil {
log.Warn().
Err(err).
Str("account-username", username).
Msg("Failed to store local username to id in cache")
}
return &acc, nil
}
func (s *Storage) FindAccountByPasskeyId(pkeyId []byte) (*Account, error) {
defer util.Untrace(util.Trace(&log.Logger))
log.Debug().Bytes("account-passkey-id", pkeyId).Msg("Looking for account")
log.Debug().Bytes("account-passkey-id", pkeyId).Msg("Checking cache first")
// Try and find the account in cache first
cacheAccId, err := s.cachePkeyIdToAccId(pkeyId)
if err == nil {
log.Info().Bytes("account-passkey-id", pkeyId).Msg("Hit passkey id in cache")
// Then always load via id since unique key access should be faster than string matching
return s.FindAccountById(*cacheAccId)
} else {
if err != errCacheNotFound {
log.Error().Err(err).Bytes("account-passkey-id", pkeyId).Msg("Problem while checking cache for account")
return nil, err
}
}
// Failed to find in cache, go the slow route of hitting the db
log.Debug().Bytes("account-passkey-id", pkeyId).Msg("Didn't hit account in cache, going to db")
acc := Account{}
res := s.db.Where("web_authn_id = ?", pkeyId).
First(&acc)
if res.Error != nil {
if res.Error == gorm.ErrRecordNotFound {
log.Info().
Bytes("account-passkey-id", pkeyId).
Msg("Local account with passkey id not found")
} else {
log.Error().Err(res.Error).Bytes("account-passkey-id", pkeyId).Msg("Failed to get local account with passkey id")
}
return nil, res.Error
}
log.Info().Bytes("account-passkey-id", pkeyId).Msg("Found account, also inserting into cache")
// if err = s.cache.Set(cacheUserIdToAccPrefix+acc.ID, &acc); err != nil {
// log.Warn().
// Err(err).
// Bytes("account-passkey-id", pkeyId).
// Msg("Found account but failed to insert into cache")
// }
// if err = s.cache.Set(cachePasskeyIdToAccIdPrefix+string(pkeyId), acc.ID); err != nil {
// log.Warn().
// Err(err).
// Bytes("account-passkey-id", pkeyId).
// Msg("Failed to store local username to id in cache")
// }
return &acc, nil
}
// Update a given account in storage and cache
func (s *Storage) UpdateAccount(acc *Account) error {
defer util.Untrace(util.Trace(&log.Logger))
// If the account is nil or doesn't have an id, error out
if acc == nil || acc.ID == "" {
return ErrInvalidData
}
res := s.db.Save(acc)
if res.Error != nil {
return res.Error
}
if err := s.cache.Set(cacheUserIdToAccPrefix+acc.ID, acc); err != nil {
return err
}
return nil
}
// Create a new empty account for future use
func (s *Storage) NewEmptyAccount() (*Account, error) {
defer util.Untrace(util.Trace(&log.Logger))
log.Debug().Msg("Creating new empty account")
acc := Account{}
// Generate the 64 bit id for passkey and webauthn stuff
log.Debug().Msg("Creating webauthn id for new account")
data := make([]byte, 64)
c, err := rand.Read(data)
for err != nil || c != len(data) || c < 64 {
data = make([]byte, 64)
c, err = rand.Read(data)
}
log.Debug().Msg("Random webauthn id for new account created")
acc.ID = uuid.NewString()
accountRole, err := s.NewEmptyRole(acc.ID)
if err != nil {
return nil, fmt.Errorf("failed to generate account role for new account: %w", err)
}
accountRole.IsUserRole = true
if err = s.UpdateRole(accountRole); err != nil {
return nil, fmt.Errorf("failed to generate account role for new account: %w", err)
}
acc.WebAuthnId = data
acc.Followers = []string{}
acc.Tags = []string{}
acc.Follows = []string{}
acc.Gender = []string{}
acc.CustomFields = []uint{}
acc.IdentifiesAs = []Being{}
acc.PasskeyCredentials = []webauthn.Credential{}
acc.Roles = []string{DefaultUserRole.Name, accountRole.Name}
acc.Icon = "placeholder"
log.Debug().Any("account", &acc).Msg("Saving new account in db")
res := s.db.Save(&acc)
if res.Error != nil {
log.Error().Err(res.Error).Msg("Failed to safe new account")
return nil, res.Error
}
log.Info().Str("account-id", acc.ID).Msg("Created new account")
return &acc, nil
}
// Create a new local account using the given handle
// The handle in this case is only the part before the domain (example: @bob@example.com would have a handle of bob)
// It also sets up a bunch of values that tend to be obvious for local accounts
func (s *Storage) NewLocalAccount(handle string) (*Account, error) {
defer util.Untrace(util.Trace(&log.Logger))
log.Trace().Caller().Send()
log.Debug().Str("account-handle", handle).Msg("Creating new local account")
acc, err := s.NewEmptyAccount()
if err != nil {
log.Error().Err(err).Msg("Failed to create empty account for use")
return nil, err
}
acc.Username = handle
// acc.Server = serverSelf
acc.ServerId = serverSelf.ID
acc.DisplayName = handle
publicKey, privateKey, err := ed25519.GenerateKey(nil)
if err != nil {
log.Error().Err(err).Msg("Failed to generate key pair for new local account")
return nil, err
}
acc.PrivateKey = privateKey
acc.PublicKey = publicKey
log.Debug().
Str("account-handle", handle).
Str("account-id", acc.ID).
Msg("Saving new local account")
res := s.db.Save(acc)
if res.Error != nil {
log.Error().Err(res.Error).Any("account-full", acc).Msg("Failed to save local account")
return nil, res.Error
}
log.Info().
Str("account-handle", handle).
Str("account-id", acc.ID).
Msg("Created new local account")
return acc, nil
}
func (s *Storage) DeleteAccount(accId string) error {
// TODO: Implement me
panic("Not implemented")
}
// ---- Section WebAuthn.User
// Implements the webauthn.User interface for interaction with passkeys
func (a *Account) WebAuthnID() []byte {
defer util.Untrace(util.Trace(&log.Logger))
return a.WebAuthnId
}
func (u *Account) WebAuthnName() string {
defer util.Untrace(util.Trace(&log.Logger))
return u.Username
}
func (u *Account) WebAuthnDisplayName() string {
defer util.Untrace(util.Trace(&log.Logger))
return u.DisplayName
}
func (u *Account) WebAuthnCredentials() []webauthn.Credential {
defer util.Untrace(util.Trace(&log.Logger))
return u.PasskeyCredentials
}
func (u *Account) WebAuthnIcon() string {
defer util.Untrace(util.Trace(&log.Logger))
return ""
}
// ---- Section passkey.User
// Implements the passkey.User interface
func (u *Account) PutCredential(new webauthn.Credential) {
defer util.Untrace(util.Trace(&log.Logger))
u.PasskeyCredentials = append(u.PasskeyCredentials, new)
}
// Section passkey.UserStore
// Implements the passkey.UserStore interface
func (s *Storage) GetOrCreateUser(userID string) passkey.User {
defer util.Untrace(util.Trace(&log.Logger))
log.Debug().
Str("account-handle", userID).
Msg("Looking for or creating account for passkey stuff")
acc := &Account{}
res := s.db.Where(Account{Username: userID, ServerId: serverSelf.ID}).
First(acc)
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
log.Debug().Str("account-handle", userID)
var err error
acc, err = s.NewLocalAccount(userID)
if err != nil {
log.Error().
Err(err).
Str("account-handle", userID).
Msg("Failed to create new account for webauthn request")
return nil
}
}
return acc
}
func (s *Storage) GetUserByWebAuthnId(id []byte) passkey.User {
defer util.Untrace(util.Trace(&log.Logger))
log.Debug().Bytes("webauthn-id", id).Msg("Looking for account with webauthn id")
acc := Account{}
res := s.db.Where(Account{WebAuthnId: id}).First(&acc)
if res.Error != nil {
log.Error().
Err(res.Error).
Bytes("webauthn-id", id).
Msg("Failed to find user with webauthn ID")
return nil
}
log.Info().Msg("Found account with given webauthn id")
return &acc
}
func (s *Storage) SaveUser(rawUser passkey.User) {
defer util.Untrace(util.Trace(&log.Logger))
user, ok := rawUser.(*Account)
if !ok {
log.Error().Any("raw-user", rawUser).Msg("Failed to cast raw user to db account")
}
s.db.Save(user)
}