website/pkg/models/user.go

842 lines
22 KiB
Go
Raw Permalink Normal View History

package models
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
2022-08-26 04:21:46 +00:00
"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"`
2024-04-25 04:29:44 +00:00
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
2024-04-25 04:29:44 +00:00
Birthdate time.Time
Certified bool
2023-05-24 03:04:17 +00:00
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
Admin Groups & Permissions Add a permission system for admin users so you can lock down specific admins to a narrower set of features instead of them all having omnipotent powers. * New page: Admin Dashboard -> Admin Permissions Management * Permissions are handled in the form of 'scopes' relevant to each feature or action on the site. Scopes are assigned to Groups, and in turn, admin user accounts are placed in those Groups. * The Superusers group (scope '*') has wildcard permission to all scopes. The permissions dashboard has a create-once action to initialize the Superusers for the first admin who clicks on it, and places that admin in the group. The following are the exhaustive list of permission changes on the site: * Moderator scopes: * Chat room (enter the room with Operator permission) * Forums (can edit or delete user posts on the forum) * Photo Gallery (can see all private/friends-only photos on the site gallery or user profile pages) * Certification photos (with nuanced sub-action permissions) * Approve: has access to the Pending tab to act on incoming pictures * List: can paginate thru past approved/rejected photos * View: can bring up specific user cert photo from their profile * The minimum requirement is Approve or else no cert photo page will load for your admin user. * User Actions (each action individually scoped) * Impersonate * Ban * Delete * Promote to admin * Inner circle whitelist: no longer are admins automatically part of the inner circle unless they have a specialized scope attached. The AdminRequired decorator may also apply scopes on an entire admin route. The following routes have scopes to limit them: * Forum Admin (manage forums and their settings) * Remove from inner circle
2023-08-02 03:39:48 +00:00
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
}
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 {
Admin Groups & Permissions Add a permission system for admin users so you can lock down specific admins to a narrower set of features instead of them all having omnipotent powers. * New page: Admin Dashboard -> Admin Permissions Management * Permissions are handled in the form of 'scopes' relevant to each feature or action on the site. Scopes are assigned to Groups, and in turn, admin user accounts are placed in those Groups. * The Superusers group (scope '*') has wildcard permission to all scopes. The permissions dashboard has a create-once action to initialize the Superusers for the first admin who clicks on it, and places that admin in the group. The following are the exhaustive list of permission changes on the site: * Moderator scopes: * Chat room (enter the room with Operator permission) * Forums (can edit or delete user posts on the forum) * Photo Gallery (can see all private/friends-only photos on the site gallery or user profile pages) * Certification photos (with nuanced sub-action permissions) * Approve: has access to the Pending tab to act on incoming pictures * List: can paginate thru past approved/rejected photos * View: can bring up specific user cert photo from their profile * The minimum requirement is Approve or else no cert photo page will load for your admin user. * User Actions (each action individually scoped) * Impersonate * Ban * Delete * Promote to admin * Inner circle whitelist: no longer are admins automatically part of the inner circle unless they have a specialized scope attached. The AdminRequired decorator may also apply scopes on an entire admin route. The following routes have scopes to limit them: * Forum Admin (manage forums and their settings) * Remove from inner circle
2023-08-02 03:39:48 +00:00
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
}
2022-08-14 05:44:57 +00:00
// 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)
2022-08-14 05:44:57 +00:00
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
}
2023-10-08 20:35:11 +00:00
// 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
}
2024-01-27 21:57:24 +00:00
// 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
}
// 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
}
2022-08-14 05:44:57 +00:00
// UserSearch config.
type UserSearch struct {
Username string
Gender string
Orientation string
MaritalStatus string
2023-08-30 04:10:00 +00:00
HereFor string
ProfileText *Search
NearCity *WorldCities
Certified bool
2023-09-02 00:20:34 +00:00
NotCertified bool
ShyAccounts bool
IsBanned bool
IsDisabled bool
IsAdmin bool // search for admin users
Friends bool
2024-06-27 04:27:03 +00:00
Liked bool
AgeMin int
AgeMax int
2022-08-14 05:44:57 +00:00
}
// SearchUsers from the perspective of a given user.
func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, error) {
2022-08-14 05:44:57 +00:00
if search == nil {
search = &UserSearch{}
}
var (
users = []*User{}
query *gorm.DB
2023-08-20 02:11:33 +00:00
joins string // GPS location join.
wheres = []string{}
placeholders = []interface{}{}
blockedUserIDs = BlockedUserIDs(user)
2023-08-20 02:11:33 +00:00
myLocation = GetUserLocation(user.ID)
2022-08-14 05:44:57 +00:00
)
2023-08-20 02:11:33 +00:00
// Sort by distance? Requires PostgreSQL.
if pager.Sort == "distance" || search.NearCity != nil {
2023-08-20 02:11:33 +00:00
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) {
2024-03-30 03:35:41 +00:00
return users, errors.New("can not sort members by distance because your location is not known")
2023-08-20 02:11:33 +00:00
}
// Which location to search from?
var (
latitude, longitude = myLocation.Latitude, myLocation.Longitude
)
if search.NearCity != nil {
latitude = search.NearCity.Latitude
longitude = search.NearCity.Longitude
}
2023-08-20 02:11:33 +00:00
// 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,
2023-08-20 02:11:33 +00:00
)
}
if len(blockedUserIDs) > 0 {
2024-08-24 06:34:12 +00:00
wheres = append(wheres, "users.id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
if search.Username != "" {
ilike := "%" + strings.TrimSpace(strings.ToLower(search.Username)) + "%"
2024-08-24 06:34:12 +00:00
wheres = append(wheres, "(users.username LIKE ? OR users.name ILIKE ?)")
placeholders = append(placeholders, ilike, ilike)
2022-08-14 05:44:57 +00:00
}
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)
}
2023-08-30 04:10:00 +00:00
if search.HereFor != "" {
wheres = append(wheres, `
EXISTS (
SELECT 1 FROM profile_fields
2023-08-30 04:14:06 +00:00
WHERE user_id = users.id AND name = ? AND value LIKE ?
2023-08-30 04:10:00 +00:00
)
`)
2023-08-30 04:14:06 +00:00
placeholders = append(placeholders, "here_for", "%"+search.HereFor+"%")
2023-08-30 04:10:00 +00:00
}
// 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 {
2024-08-24 06:09:27 +00:00
wheres = append(wheres, "users.status IN ?")
placeholders = append(placeholders, statuses)
} else {
2024-08-24 06:09:27 +00:00
wheres = append(wheres, "users.status = ?")
placeholders = append(placeholders, UserStatusActive)
}
// Certified filter (including if Shy Accounts are asked for)
2022-08-14 05:44:57 +00:00
if search.Certified {
2024-08-24 06:09:27 +00:00
wheres = append(wheres, "users.certified = ?")
placeholders = append(placeholders, search.Certified)
2022-08-14 05:44:57 +00:00
}
2023-09-02 00:20:34 +00:00
// Expressly Not Certified filtering
if search.NotCertified {
2024-08-24 06:09:27 +00:00
wheres = append(wheres, "users.certified = ?")
placeholders = append(placeholders, false)
2023-09-02 00:20:34 +00:00
}
if search.IsAdmin {
2024-08-24 06:09:27 +00:00
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,
)
}
2024-06-27 04:27:03 +00:00
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)
}
2024-08-24 06:09:27 +00:00
query = (&User{}).Preload().Joins(
"JOIN certification_photos ON (certification_photos.user_id = users.id)",
)
2023-08-20 02:11:33 +00:00
if joins != "" {
query = query.Joins(joins)
}
query = query.Where(
2022-08-14 05:44:57 +00:00
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)
2022-08-14 05:44:57 +00:00
query.Model(&User{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users)
// Inject relationship booleans.
SetUserRelationships(user, users)
2022-08-14 05:44:57 +00:00
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) {
2022-08-14 00:42:42 +00:00
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{}
2022-08-14 00:42:42 +00:00
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
}
2023-12-21 22:59:41 +00:00
// 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)
}
// 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
}
Admin Groups & Permissions Add a permission system for admin users so you can lock down specific admins to a narrower set of features instead of them all having omnipotent powers. * New page: Admin Dashboard -> Admin Permissions Management * Permissions are handled in the form of 'scopes' relevant to each feature or action on the site. Scopes are assigned to Groups, and in turn, admin user accounts are placed in those Groups. * The Superusers group (scope '*') has wildcard permission to all scopes. The permissions dashboard has a create-once action to initialize the Superusers for the first admin who clicks on it, and places that admin in the group. The following are the exhaustive list of permission changes on the site: * Moderator scopes: * Chat room (enter the room with Operator permission) * Forums (can edit or delete user posts on the forum) * Photo Gallery (can see all private/friends-only photos on the site gallery or user profile pages) * Certification photos (with nuanced sub-action permissions) * Approve: has access to the Pending tab to act on incoming pictures * List: can paginate thru past approved/rejected photos * View: can bring up specific user cert photo from their profile * The minimum requirement is Approve or else no cert photo page will load for your admin user. * User Actions (each action individually scoped) * Impersonate * Ban * Delete * Promote to admin * Inner circle whitelist: no longer are admins automatically part of the inner circle unless they have a specialized scope attached. The AdminRequired decorator may also apply scopes on an entire admin route. The following routes have scopes to limit them: * Forum Admin (manage forums and their settings) * Remove from inner circle
2023-08-02 03:39:48 +00:00
// 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
2022-08-16 05:33:17 +00:00
// 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
}
}
2023-09-14 04:28:38 +00:00
// 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:
2023-09-14 04:28:38 +00:00
return "/static/img/shy-private.png"
case PhotoFriends:
2023-09-14 04:28:38 +00:00
return "/static/img/shy-friends.png"
}
2023-09-14 04:28:38 +00:00
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)
}
}
// 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 ""
}
2023-10-07 20:24:07 +00:00
// 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"
}
2023-10-11 02:30:39 +00:00
// 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()))
2023-10-11 02:54:43 +00:00
startDate = now.Add(-6 * time.Hour)
endDate = now.Add(60 * time.Hour)
2023-10-11 02:30:39 +00:00
)
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()
}