package models import ( "bytes" "encoding/json" "errors" "fmt" "strings" "sync" "time" "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/utility" "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 `json:"-"` 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 CreatedAt time.Time `gorm:"index"` UpdatedAt time.Time `gorm:"index"` LastLoginAt time.Time `gorm:"index"` // Relational tables. ProfileField []ProfileField `json:"-"` ProfilePhotoID *uint64 ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"` AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;" json:"-"` // Current user's relationship to this user -- not stored in DB. UserRelationship UserRelationship `gorm:"-"` // Caches cachePhotoTypes map[PhotoVisibility]struct{} cacheBlockedUserIDs []uint64 cachePhotoIDs []uint64 // Feature mutexes. muStatistic sync.Mutex } type UserVisibility string const ( UserVisibilityPublic UserVisibility = "public" UserVisibilityExternal UserVisibility = "external" UserVisibilityPrivate UserVisibility = "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").Preload("AdminGroups.Scopes") } // 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 } // GetUsersAlphabetically queries for multiple user IDs and returns them sorted by username. // // Note: it doesn't respect blocked lists or a viewer context. Used for things like the forum moderators lists. func GetUsersAlphabetically(userIDs []uint64) ([]*User, error) { var ( users = []*User{} result = (&User{}).Preload().Where( "id IN ?", userIDs, ).Order("username asc").Find(&users) ) // Inject user relationships. for _, user := range users { SetUserRelationships(user, users) } return users, result.Error } // GetUsersByUsernames queries for multiple usernames and returns users in the same order. func GetUsersByUsernames(currentUser *User, usernames []string) ([]*User, error) { // Map the usernames. var ( usermap = map[string]*User{} set = map[string]interface{}{} distinct = []string{} ) // Uniqueify usernames. for _, name := range usernames { if _, ok := set[name]; ok { continue } set[name] = nil distinct = append(distinct, name) } var ( users = []*User{} result = (&User{}).Preload().Where("username IN ?", distinct).Find(&users) ) if result.Error != nil { return nil, result.Error } // Map users. for _, user := range users { usermap[user.Username] = user } // Inject relationships. SetUserRelationships(currentUser, users) // Re-order them per the original sequence. var ordered = []*User{} for _, name := range usernames { if user, ok := usermap[name]; ok { ordered = append(ordered, user) } } return ordered, 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 } // IsValidUsername checks if a username is available and not reserved. func IsValidUsername(username string) error { // Check the formatting of the name. if !config.UsernameRegexp.MatchString(username) { return errors.New("Your username must consist of only numbers, letters, - . and be 3-32 characters.") } // Reserved username check. for _, cmp := range config.ReservedUsernames { if username == cmp { return errors.New("That username is reserved, please choose a different username.") } } // Does the username already exist? if _, err := FindUser(username); err == nil { return errors.New("That username already exists. Please try a different username.") } return nil } // PingLastLoginAt refreshes the user's "last logged in" time. func (u *User) PingLastLoginAt() error { // Also ping their daily active user statistic. if err := LogDailyActiveUser(u); err != nil { log.Error("PingLastLoginAt(%s): couldn't log daily active user statistic: %s", u.Username, err) } u.LastLoginAt = time.Now() return u.Save() } // IsBanned returns if the user account is banned. func (u *User) IsBanned() bool { return u.Status == UserStatusBanned } // 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 } // CanBeSeenBy checks whether the user can be seen to exist by the viewer. // // An admin viewer can always see them, but a user may be hidden to others when they are // blocking, disabled or banned. // // The user should always be given a Not Found page so they can't tell the user even // exists. The returned error will include a specific reason, for debugging purposes. func (u *User) CanBeSeenBy(viewer *User) error { if viewer.IsAdmin { return nil } // Banned or disabled? Only admin can view then. if u.Status != UserStatusActive { return fmt.Errorf("user status is %s", u.Status) } // Is either one blocking? if IsBlocking(viewer.ID, u.ID) && !viewer.IsAdmin { return fmt.Errorf("users block each other") } return nil } // UserSearch config. type UserSearch struct { Username string Gender string Orientation string MaritalStatus string HereFor string ProfileText *Search NearCity *WorldCities Certified bool NotCertified bool ShyAccounts bool IsBanned bool IsDisabled bool IsAdmin bool // search for admin users Friends bool Liked 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 joins string // GPS location join. wheres = []string{} placeholders = []interface{}{} blockedUserIDs = BlockedUserIDs(user) myLocation = GetUserLocation(user.ID) ) // Sort by distance? Requires PostgreSQL. if pager.Sort == "distance" || search.NearCity != nil { if !config.Current.Database.IsPostgres { return users, errors.New("ordering by distance requires PostgreSQL with the PostGIS extension") } // If the current user doesn't have their location on file, they can't do this. if myLocation.Source == LocationSourceNone || (myLocation.Latitude == 0 && myLocation.Longitude == 0) { return users, errors.New("can not sort members by distance because your location is not known") } // Which location to search from? var ( latitude, longitude = myLocation.Latitude, myLocation.Longitude ) if search.NearCity != nil { latitude = search.NearCity.Latitude longitude = search.NearCity.Longitude } // Only query for users who have locations. joins = "JOIN user_locations ON (user_locations.user_id = users.id)" wheres = append(wheres, "user_locations.latitude IS NOT NULL", "user_locations.longitude IS NOT NULL", "user_locations.latitude <> 0", "user_locations.longitude <> 0", ) pager.Sort = fmt.Sprintf(`ST_Distance( ST_MakePoint(user_locations.longitude, user_locations.latitude)::geography, ST_MakePoint(%f, %f)::geography)`, longitude, latitude, ) } if len(blockedUserIDs) > 0 { wheres = append(wheres, "users.id NOT IN ?") placeholders = append(placeholders, blockedUserIDs) } if search.Username != "" { ilike := "%" + strings.TrimSpace(strings.ToLower(search.Username)) + "%" wheres = append(wheres, "(users.username LIKE ? OR users.name ILIKE ?)") 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.HereFor != "" { wheres = append(wheres, ` EXISTS ( SELECT 1 FROM profile_fields WHERE user_id = users.id AND name = ? AND value LIKE ? ) `) placeholders = append(placeholders, "here_for", "%"+search.HereFor+"%") } // Profile text search. if terms := search.ProfileText; terms != nil { for _, term := range terms.Includes { var ilike = "%" + strings.ToLower(term) + "%" wheres = append(wheres, ` EXISTS ( SELECT 1 FROM profile_fields WHERE user_id = users.id AND name IN ? AND value ILIKE ? ) `) placeholders = append(placeholders, config.EssayProfileFields, ilike) } for _, term := range terms.Excludes { var ilike = "%" + strings.ToLower(term) + "%" wheres = append(wheres, ` NOT EXISTS ( SELECT 1 FROM profile_fields WHERE user_id = users.id AND name IN ? AND value ILIKE ? ) `) placeholders = append(placeholders, config.EssayProfileFields, ilike) } } // Only admin user can show disabled/banned users. var statuses = []string{} if user.HasAdminScope(config.ScopeUserBan) { if search.IsBanned { statuses = append(statuses, UserStatusBanned) } if search.IsDisabled { statuses = append(statuses, UserStatusDisabled) } } // Non-admin user only ever sees active accounts. if user.IsAdmin && len(statuses) > 0 { wheres = append(wheres, "users.status IN ?") placeholders = append(placeholders, statuses) } else { wheres = append(wheres, "users.status = ?") placeholders = append(placeholders, UserStatusActive) } // Certified filter (including if Shy Accounts are asked for) if search.Certified { wheres = append(wheres, "users.certified = ?") placeholders = append(placeholders, search.Certified) } // Expressly Not Certified filtering if search.NotCertified { wheres = append(wheres, "users.certified = ?") placeholders = append(placeholders, false) } if search.IsAdmin { wheres = append(wheres, "users.is_admin = true") } if search.ShyAccounts { a, b := WhereClauseShyAccounts() wheres = append(wheres, a) placeholders = append(placeholders, b...) } if search.Friends { wheres = append(wheres, ` EXISTS ( SELECT 1 FROM friends WHERE source_user_id = ? AND target_user_id = users.id AND approved = ? ) `) placeholders = append(placeholders, user.ID, true, ) } if search.Liked { wheres = append(wheres, ` EXISTS ( SELECT 1 FROM likes WHERE user_id = ? AND table_name = 'users' AND table_id = users.id ) `) placeholders = append(placeholders, user.ID, ) } if search.AgeMin > 0 { date := time.Now().AddDate(-search.AgeMin, 0, 0) wheres = append(wheres, ` birthdate <= ? AND NOT EXISTS ( SELECT 1 FROM profile_fields WHERE user_id = users.id AND name = 'hide_age' AND value = 'true' ) `) placeholders = append(placeholders, date) } if search.AgeMax > 0 { date := time.Now().AddDate(-search.AgeMax-1, 0, 0) wheres = append(wheres, ` birthdate >= ? AND NOT EXISTS ( SELECT 1 FROM profile_fields WHERE user_id = users.id AND name = 'hide_age' AND value = 'true' ) `) placeholders = append(placeholders, date) } query = (&User{}).Preload().Joins( "JOIN certification_photos ON (certification_photos.user_id = users.id)", ) if joins != "" { query = query.Joins(joins) } query = query.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 } // MapAdminUsers returns a MapUsers result for all admin user accounts on the site. func MapAdminUsers(user *User) (UserMap, error) { adminUsers, err := ListAdminUsers() if err != nil { return nil, err } var userIDs = []uint64{} for _, user := range adminUsers { userIDs = append(userIDs, user.ID) } return MapUsers(user, userIDs) } // CountBlockedAdminUsers returns a count of how many admin users the current user has blocked, out of how many total. func CountBlockedAdminUsers(user *User) (count, total int64) { // Count the blocked admins. DB.Model(&User{}).Select( "count(users.id) AS cnt", ).Joins( "JOIN blocks ON (blocks.target_user_id = users.id)", ).Where( "blocks.source_user_id = ? AND users.is_admin IS TRUE", user.ID, ).Count(&count) // And the total number of available admins. total = CountAdminUsers() return } // CountAdminUsers returns a count of how many admin users exist on the site. func CountAdminUsers() (count int64) { DB.Model(&User{}).Select( "count(id) AS cnt", ).Where( "users.is_admin IS TRUE", ).Count(&count) return } // 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 } // MapUsersByUsername looks up a set of users in bulk and returns a UsernameMap suitable for templates. // // It is like MapUsers but by username instead of ID. func MapUsersByUsername(usernames []string) (UsernameMap, error) { var ( usermap = UsernameMap{} set = map[string]interface{}{} distinct = []string{} ) // Uniqueify users. for _, uid := range usernames { if _, ok := set[uid]; ok { continue } set[uid] = nil distinct = append(distinct, uid) } var ( users = []*User{} result = (&User{}).Preload().Where("username IN ?", distinct).Find(&users) ) if result.Error == nil { for _, row := range users { usermap[row.Username] = row } } // Assert we got the expected count. if len(usermap) != len(distinct) { return usermap, fmt.Errorf("didn't get all expected users (expected %d, got %d)", len(distinct), len(usermap)) } return usermap, result.Error } // UsernameMap helps map a set of users to look up by ID. type UsernameMap map[string]*User // 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 } } // VisibleAvatarURL returns a URL to the user's avatar taking into account // their relationship with the current user. For example, if the avatar is // friends-only and the current user can't see it, returns the path to the // yellow placeholder avatar instead. // // Expects that UserRelationships are available on the user. func (u *User) VisibleAvatarURL(currentUser *User) string { canSee, visibility := u.CanSeeProfilePicture(currentUser) if canSee { return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename } switch visibility { case PhotoPrivate: return "/static/img/shy-private.png" case PhotoFriends: return "/static/img/shy-friends.png" } return "/static/img/shy.png" } // CanSeeProfilePicture returns whether the current user can see the user's profile picture. // // Returns a boolean (false if currentUser can't see) and the Visibility setting of the profile photo. // // If the user has no profile photo, returns (false, PhotoPublic) which should manifest as the blue shy.png placeholder image. func (u *User) CanSeeProfilePicture(currentUser *User) (bool, PhotoVisibility) { if !u.UserRelationship.Computed && currentUser != nil { SetUserRelationships(currentUser, []*User{u}) } visibility := u.ProfilePhoto.Visibility if visibility == PhotoPrivate && !u.UserRelationship.IsPrivateGranted { // Private photo return false, visibility } else if visibility == PhotoFriends && !u.UserRelationship.IsFriend { // Friends only return false, visibility } else if u.ProfilePhoto.CroppedFilename != "" { // Happy path return true, visibility } return false, PhotoPublic } // 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 } // SaveNewPassword updates a user's password and saves their record to the database. func (u *User) SaveNewPassword(password string) error { if err := u.HashPassword(password); err != nil { return err } return u.Save() } // 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 } log.Debug("User(%s): append ProfileField %s", u.Username, name) u.ProfileField = append(u.ProfileField, ProfileField{ Name: name, Value: value, }) if err := u.Save(); err != nil { log.Error("User(%s).SetProfileField(%s): error saving after append new field: %s", u.Username, name, err) } } // DeleteProfileField removes a stored profile field. func (u *User) DeleteProfileField(name string) error { res := DB.Exec( "DELETE FROM profile_fields WHERE user_id=? AND name=?", u.ID, name, ) return res.Error } // 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 "" } // GetProfileFieldOr returns a default string (like "n/a") if the profile field is not set. func (u *User) GetProfileFieldOr(name, or string) string { if value := u.GetProfileField(name); value != "" { return value } return or } // GetDisplayAge returns the user's age dependent on their hide-my-age setting. func (u *User) GetDisplayAge() string { if !u.Birthdate.IsZero() && u.GetProfileField("hide_age") != "true" { return fmt.Sprintf("%dyo", utility.Age(u.Birthdate)) } return "n/a" } // IsBirthday returns whether it is currently the user's birthday (+- a day for time zones). func (u *User) IsBirthday() bool { if u.Birthdate.IsZero() { return false } // Window of time to be valid. var ( now, _ = time.Parse(time.DateOnly, time.Now().Format(time.DateOnly)) bday, _ = time.Parse(time.DateOnly, fmt.Sprintf("%d-%d-%d", now.Year(), u.Birthdate.Month(), u.Birthdate.Day())) startDate = now.Add(-6 * time.Hour) endDate = now.Add(60 * time.Hour) ) return bday.Before(endDate) && bday.After(startDate) } // 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() }