package storage import ( "crypto/rand" "errors" "strings" "time" "github.com/go-webauthn/webauthn/webauthn" "github.com/mstarongithub/passkey" "github.com/rs/zerolog/log" "gitlab.com/mstarongitlab/linstrom/ap" "gitlab.com/mstarongitlab/linstrom/config" "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 // Handle 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 Handle 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"` Remote bool // Whether the account is a local or remote one Server string // The url of the server this account is from 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 handles this account follows Followers []string `gorm:"serializer:json"` // List of handles 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 PublicKeyPem *string // 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] Gender []string `gorm:"serializer:json"` // The roles assocciated with an account Roles []string `gorm:"serializer:json"` // --- And internal account stuff --- // Still public fields since they wouldn't be able to be stored in the db otherwise PrivateKeyPem *string // 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 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) { log.Trace().Caller().Send() 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) { log.Trace().Caller().Send() 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") res := s.db.First(acc, id) if res.Error != nil { if errors.Is(res.Error, gorm.ErrRecordNotFound) { log.Warn().Str("account-id", id).Msg("Account not found") } 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 } // Update a given account in storage and cache func (s *Storage) UpdateAccount(acc *Account) error { // 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) { log.Trace().Caller().Send() log.Debug().Msg("Creating new empty account") acc := Account{} // Generate the 64 bit id for passkey and webauthn stuff 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) } 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{} 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) { 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.Handle = handle acc.Server = config.GlobalConfig.General.GetFullDomain() acc.Remote = false acc.DisplayName = handle 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 } // ---- Section WebAuthn.User // Implements the webauthn.User interface for interaction with passkeys func (a *Account) WebAuthnID() []byte { log.Trace().Caller().Send() return a.WebAuthnId } func (u *Account) WebAuthnName() string { log.Trace().Caller().Send() return u.Handle } func (u *Account) WebAuthnDisplayName() string { log.Trace().Caller().Send() return u.DisplayName } func (u *Account) WebAuthnCredentials() []webauthn.Credential { log.Trace().Caller().Send() return u.PasskeyCredentials } func (u *Account) WebAuthnIcon() string { log.Trace().Caller().Send() return "" } // ---- Section passkey.User // Implements the passkey.User interface func (u *Account) PutCredential(new webauthn.Credential) { log.Trace().Caller().Send() u.PasskeyCredentials = append(u.PasskeyCredentials, new) } // Section passkey.UserStore // Implements the passkey.UserStore interface func (s *Storage) GetOrCreateUser(userID string) passkey.User { log.Trace().Caller().Send() log.Debug(). Str("account-handle", userID). Msg("Looking for or creating account for passkey stuff") acc := &Account{} res := s.db.Where(Account{Handle: userID, Server: config.GlobalConfig.General.GetFullDomain()}). 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 { log.Trace().Caller().Send() 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) { log.Trace().Caller().Send() 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) }