package models import ( "bytes" "encoding/json" "errors" "strings" "time" "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) // User account table. type User struct { ID uint64 `gorm:"primaryKey"` Username string `gorm:"uniqueIndex"` Email string `gorm:"uniqueIndex"` HashedPassword string IsAdmin bool `gorm:"index"` Status UserStatus `gorm:"index"` // active, disabled Visibility UserVisibility `gorm:"index"` // public, private Name *string Birthdate time.Time Certified bool Explicit bool `gorm:"index"` // user has opted-in to see explicit content InnerCircle bool `gorm:"index"` // user is in the inner circle CreatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"` LastLoginAt time.Time `gorm:"index"` // Relational tables. ProfileField []ProfileField ProfilePhotoID *uint64 ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"` // Current user's relationship to this user -- not stored in DB. UserRelationship UserRelationship `gorm:"-"` // Caches cachePhotoTypes map[PhotoVisibility]struct{} } type UserVisibility string const ( UserVisibilityPublic UserVisibility = "public" UserVisibilityExternal = "external" UserVisibilityPrivate = "private" ) // All visibility options. var UserVisibilityOptions = []UserVisibility{ UserVisibilityPublic, UserVisibilityExternal, UserVisibilityPrivate, } // Preload related tables for the user (classmethod). func (u *User) Preload() *gorm.DB { return DB.Preload("ProfileField").Preload("ProfilePhoto") } // UserStatus options. type UserStatus string const ( UserStatusActive = "active" UserStatusDisabled = "disabled" UserStatusBanned = "banned" ) // CreateUser. It is assumed username and email are correctly formatted. func CreateUser(username, email, password string) (*User, error) { // Verify username and email are unique. if _, err := FindUser(username); err == nil { return nil, errors.New("That username already exists. Please try a different username.") } else if _, err := FindUser(email); err == nil { return nil, errors.New("That email address is already registered.") } u := &User{ Username: username, Email: email, Status: UserStatusActive, Visibility: UserVisibilityPublic, } if err := u.HashPassword(password); err != nil { return nil, err } result := DB.Create(u) return u, result.Error } // GetUser by ID. func GetUser(userId uint64) (*User, error) { user := &User{} result := user.Preload().First(&user, userId) return user, result.Error } // GetUsers queries for multiple user IDs and returns users in the same order. func GetUsers(currentUser *User, userIDs []uint64) ([]*User, error) { userMap, err := MapUsers(currentUser, userIDs) if err != nil { return nil, err } // Re-order them per the original sequence. var users = []*User{} for _, uid := range userIDs { if user, ok := userMap[uid]; ok { users = append(users, user) } } return users, nil } // FindUser by username or email. func FindUser(username string) (*User, error) { if username == "" { return nil, errors.New("username is required") } u := &User{} if strings.ContainsRune(username, '@') { result := u.Preload().Where("email = ?", username).Limit(1).First(u) return u, result.Error } result := u.Preload().Where("username = ?", username).Limit(1).First(u) return u, result.Error } // IsShy returns whether the user might have an "empty" profile from the perspective of anybody. // // An empty profile means their profile is Private or else ALL of their photos are non-public; so that // somebody viewing their page might see nothing at all from them and consider them a "blank" profile. func (u *User) IsShy() bool { // Non-certified users are considered empty. if !u.Certified { return true } // Private profile automatically applies. if u.Visibility == UserVisibilityPrivate { return true } // If ALL of our photos are non-public, that counts too. var photoTypes = u.DistinctPhotoTypes() if _, ok := photoTypes[PhotoPublic]; !ok { log.Info("IsEmptyProfile: true because visibilities %+v did not include public", photoTypes) return true } return false } // IsShyFrom tells whether the user is shy from the perspective of the other user. // // That is, depending on our profile visibility and friendship status. func (u *User) IsShyFrom(other *User) bool { // If we are not a private profile, we're shy from nobody. if u.Visibility != UserVisibilityPrivate { return false } // Not shy from our friends. if AreFriends(u.ID, other.ID) { return false } // Our profile must be private & we are not friended, we are shy. return true } // UserSearch config. type UserSearch struct { EmailOrUsername string Gender string Orientation string MaritalStatus string Certified bool InnerCircle bool AgeMin int AgeMax int } // SearchUsers from the perspective of a given user. func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, error) { if search == nil { search = &UserSearch{} } var ( users = []*User{} query *gorm.DB wheres = []string{} placeholders = []interface{}{} blockedUserIDs = BlockedUserIDs(user.ID) ) if len(blockedUserIDs) > 0 { wheres = append(wheres, "id NOT IN ?") placeholders = append(placeholders, blockedUserIDs) } if search.EmailOrUsername != "" { ilike := "%" + strings.TrimSpace(strings.ToLower(search.EmailOrUsername)) + "%" wheres = append(wheres, "(email LIKE ? OR username LIKE ?)") placeholders = append(placeholders, ilike, ilike) } if search.Gender != "" { wheres = append(wheres, ` EXISTS ( SELECT 1 FROM profile_fields WHERE user_id = users.id AND name = ? AND value = ? ) `) placeholders = append(placeholders, "gender", search.Gender) } if search.Orientation != "" { wheres = append(wheres, ` EXISTS ( SELECT 1 FROM profile_fields WHERE user_id = users.id AND name = ? AND value = ? ) `) placeholders = append(placeholders, "orientation", search.Orientation) } if search.MaritalStatus != "" { wheres = append(wheres, ` EXISTS ( SELECT 1 FROM profile_fields WHERE user_id = users.id AND name = ? AND value = ? ) `) placeholders = append(placeholders, "marital_status", search.MaritalStatus) } if search.Certified { wheres = append(wheres, "certified = ?", "status = ?") placeholders = append(placeholders, search.Certified, UserStatusActive) } if search.InnerCircle { wheres = append(wheres, "inner_circle = ? OR is_admin = ?") placeholders = append(placeholders, true, true) } if search.AgeMin > 0 { date := time.Now().AddDate(-search.AgeMin, 0, 0) wheres = append(wheres, "birthdate <= ?") placeholders = append(placeholders, date) } if search.AgeMax > 0 { date := time.Now().AddDate(-search.AgeMax-1, 0, 0) wheres = append(wheres, "birthdate >= ?") placeholders = append(placeholders, date) } query = (&User{}).Preload().Where( strings.Join(wheres, " AND "), placeholders..., ).Order(pager.Sort) query.Model(&User{}).Count(&pager.Total) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users) // Inject relationship booleans. SetUserRelationships(user, users) return users, result.Error } // UserMap helps map a set of users to look up by ID. type UserMap map[uint64]*User // MapUsers looks up a set of user IDs in bulk and returns a UserMap suitable for templates. // Useful to avoid circular reference issues with Photos especially; the Site Gallery queries // photos of ALL users and MapUsers helps stitch them together for the frontend. func MapUsers(user *User, userIDs []uint64) (UserMap, error) { var ( usermap = UserMap{} set = map[uint64]interface{}{} distinct = []uint64{} ) // Uniqueify users. for _, uid := range userIDs { if _, ok := set[uid]; ok { continue } set[uid] = nil distinct = append(distinct, uid) } var ( users = []*User{} result = (&User{}).Preload().Where("id IN ?", distinct).Find(&users) ) // Inject user relationships. if user != nil { SetUserRelationships(user, users) } if result.Error == nil { for _, row := range users { usermap[row.ID] = row } } return usermap, result.Error } // Has a user ID in the map? func (um UserMap) Has(id uint64) bool { _, ok := um[id] return ok } // Get a user from the UserMap. func (um UserMap) Get(id uint64) *User { if user, ok := um[id]; ok { return user } return nil } // NameOrUsername returns the name (if not null or empty) or the username. func (u *User) NameOrUsername() string { if u.Name != nil && len(*u.Name) > 0 { return *u.Name } else { return u.Username } } // HashPassword sets the user's hashed (bcrypt) password. func (u *User) HashPassword(password string) error { passwd, err := bcrypt.GenerateFromPassword([]byte(password), config.BcryptCost) if err != nil { return err } u.HashedPassword = string(passwd) return nil } // CheckPassword verifies the password is correct. Returns nil on success. func (u *User) CheckPassword(password string) error { return bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password)) } // SetProfileField sets or creates a named profile field. func (u *User) SetProfileField(name, value string) { // Check if it exists. log.Debug("User(%s).SetProfileField(%s, %s)", u.Username, name, value) var exists bool for _, field := range u.ProfileField { log.Debug("\tCheck existing field %s", field.Name) if field.Name == name { log.Debug("\tFound existing field!") changed := field.Value != value field.Value = value exists = true // Save it now. TODO: otherwise gorm doesn't know we changed // it and it won't be inserted when the User is saved. But // this is probably not performant to do! if changed { DB.Save(&field) } break } } if exists { return } u.ProfileField = append(u.ProfileField, ProfileField{ Name: name, Value: value, }) } // GetProfileField returns the value of a profile field or blank string. func (u *User) GetProfileField(name string) string { for _, field := range u.ProfileField { if field.Name == name { return field.Value } } return "" } // ProfileFieldIn checks if a substring is IN a profile field. Currently // does a naive strings.Contains(), intended for the "here_for" field. func (u *User) ProfileFieldIn(field, substr string) bool { value := u.GetProfileField(field) return strings.Contains(value, substr) } // RemoveProfilePhoto sets profile_photo_id=null to unset the foreign key. func (u *User) RemoveProfilePhoto() error { result := DB.Model(&User{}).Where("id = ?", u.ID).Update("profile_photo_id", nil) return result.Error } // Save user. func (u *User) Save() error { result := DB.Save(u) return result.Error } // Delete a user. NOTE: use the models/deletion/DeleteUser() function // instead of this to do a deep scrub of all related data! func (u *User) Delete() error { return DB.Delete(u).Error } // Print user object as pretty JSON. func (u *User) Print() string { var ( buf bytes.Buffer enc = json.NewEncoder(&buf) ) enc.SetIndent("", " ") enc.Encode(u) return buf.String() }