2022-08-10 05:10:47 +00:00
|
|
|
package models
|
|
|
|
|
|
|
|
import (
|
2022-08-13 06:11:36 +00:00
|
|
|
"bytes"
|
|
|
|
"encoding/json"
|
2022-08-10 05:10:47 +00:00
|
|
|
"errors"
|
2023-06-16 05:05:21 +00:00
|
|
|
"fmt"
|
2022-08-10 05:10:47 +00:00
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2022-08-26 04:21:46 +00:00
|
|
|
"code.nonshy.com/nonshy/website/pkg/config"
|
|
|
|
"code.nonshy.com/nonshy/website/pkg/log"
|
2023-06-16 05:05:21 +00:00
|
|
|
"code.nonshy.com/nonshy/website/pkg/utility"
|
2022-08-10 05:10:47 +00:00
|
|
|
"golang.org/x/crypto/bcrypt"
|
2022-08-13 06:11:36 +00:00
|
|
|
"gorm.io/gorm"
|
2022-08-10 05:10:47 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// User account table.
|
|
|
|
type User struct {
|
Change Logs
* Add a ChangeLog table to collect historic updates to various database tables.
* Created, Updated (with field diffs) and Deleted actions are logged, as well
as certification photo approves/denies.
* Specific items added to the change log:
* When a user photo is marked Explicit by an admin
* When users block/unblock each other
* When photo comments are posted, edited, and deleted
* When forums are created, edited, and deleted
* When forum comments are created, edited and deleted
* When a new forum thread is created
* When a user uploads or removes their own certification photo
* When an admin approves or rejects a certification photo
* When a user uploads, modifies or deletes their gallery photos
* When a friend request is sent
* When a friend request is accepted, ignored, or rejected
* When a friendship is removed
2024-02-26 01:03:36 +00:00
|
|
|
ID uint64 `gorm:"primaryKey"`
|
|
|
|
Username string `gorm:"uniqueIndex"`
|
2024-04-25 04:29:44 +00:00
|
|
|
Email string `gorm:"uniqueIndex"`
|
Change Logs
* Add a ChangeLog table to collect historic updates to various database tables.
* Created, Updated (with field diffs) and Deleted actions are logged, as well
as certification photo approves/denies.
* Specific items added to the change log:
* When a user photo is marked Explicit by an admin
* When users block/unblock each other
* When photo comments are posted, edited, and deleted
* When forums are created, edited, and deleted
* When forum comments are created, edited and deleted
* When a new forum thread is created
* When a user uploads or removes their own certification photo
* When an admin approves or rejects a certification photo
* When a user uploads, modifies or deletes their gallery photos
* When a friend request is sent
* When a friend request is accepted, ignored, or rejected
* When a friendship is removed
2024-02-26 01:03:36 +00:00
|
|
|
HashedPassword string `json:"-"`
|
2022-08-22 00:29:39 +00:00
|
|
|
IsAdmin bool `gorm:"index"`
|
|
|
|
Status UserStatus `gorm:"index"` // active, disabled
|
|
|
|
Visibility UserVisibility `gorm:"index"` // public, private
|
2022-08-10 05:10:47 +00:00
|
|
|
Name *string
|
2024-04-25 04:29:44 +00:00
|
|
|
Birthdate time.Time
|
2022-08-10 05:10:47 +00:00
|
|
|
Certified bool
|
2023-05-24 03:04:17 +00:00
|
|
|
Explicit bool `gorm:"index"` // user has opted-in to see explicit content
|
2022-08-10 05:10:47 +00:00
|
|
|
CreatedAt time.Time `gorm:"index"`
|
|
|
|
UpdatedAt time.Time `gorm:"index"`
|
2022-08-11 03:59:59 +00:00
|
|
|
LastLoginAt time.Time `gorm:"index"`
|
|
|
|
|
|
|
|
// Relational tables.
|
Change Logs
* Add a ChangeLog table to collect historic updates to various database tables.
* Created, Updated (with field diffs) and Deleted actions are logged, as well
as certification photo approves/denies.
* Specific items added to the change log:
* When a user photo is marked Explicit by an admin
* When users block/unblock each other
* When photo comments are posted, edited, and deleted
* When forums are created, edited, and deleted
* When forum comments are created, edited and deleted
* When a new forum thread is created
* When a user uploads or removes their own certification photo
* When an admin approves or rejects a certification photo
* When a user uploads, modifies or deletes their gallery photos
* When a friend request is sent
* When a friend request is accepted, ignored, or rejected
* When a friendship is removed
2024-02-26 01:03:36 +00:00
|
|
|
ProfileField []ProfileField `json:"-"`
|
2022-08-21 22:40:24 +00:00
|
|
|
ProfilePhotoID *uint64
|
2023-08-02 03:39:48 +00:00
|
|
|
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
|
Change Logs
* Add a ChangeLog table to collect historic updates to various database tables.
* Created, Updated (with field diffs) and Deleted actions are logged, as well
as certification photo approves/denies.
* Specific items added to the change log:
* When a user photo is marked Explicit by an admin
* When users block/unblock each other
* When photo comments are posted, edited, and deleted
* When forums are created, edited, and deleted
* When forum comments are created, edited and deleted
* When a new forum thread is created
* When a user uploads or removes their own certification photo
* When an admin approves or rejects a certification photo
* When a user uploads, modifies or deletes their gallery photos
* When a friend request is sent
* When a friend request is accepted, ignored, or rejected
* When a friendship is removed
2024-02-26 01:03:36 +00:00
|
|
|
AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;" json:"-"`
|
2022-09-09 04:42:20 +00:00
|
|
|
|
|
|
|
// Current user's relationship to this user -- not stored in DB.
|
|
|
|
UserRelationship UserRelationship `gorm:"-"`
|
2023-02-14 06:19:18 +00:00
|
|
|
|
|
|
|
// Caches
|
2023-10-22 22:02:24 +00:00
|
|
|
cachePhotoTypes map[PhotoVisibility]struct{}
|
|
|
|
cacheBlockedUserIDs []uint64
|
2023-10-24 02:05:34 +00:00
|
|
|
cachePhotoIDs []uint64
|
2022-08-13 06:11:36 +00:00
|
|
|
}
|
|
|
|
|
2022-08-22 00:29:39 +00:00
|
|
|
type UserVisibility string
|
|
|
|
|
|
|
|
const (
|
2022-08-30 03:00:15 +00:00
|
|
|
UserVisibilityPublic UserVisibility = "public"
|
2023-05-25 01:40:27 +00:00
|
|
|
UserVisibilityExternal UserVisibility = "external"
|
|
|
|
UserVisibilityPrivate UserVisibility = "private"
|
2022-08-22 00:29:39 +00:00
|
|
|
)
|
|
|
|
|
2022-08-30 03:00:15 +00:00
|
|
|
// All visibility options.
|
|
|
|
var UserVisibilityOptions = []UserVisibility{
|
|
|
|
UserVisibilityPublic,
|
|
|
|
UserVisibilityExternal,
|
|
|
|
UserVisibilityPrivate,
|
|
|
|
}
|
|
|
|
|
2022-08-13 06:11:36 +00:00
|
|
|
// Preload related tables for the user (classmethod).
|
|
|
|
func (u *User) Preload() *gorm.DB {
|
2023-08-02 03:39:48 +00:00
|
|
|
return DB.Preload("ProfileField").Preload("ProfilePhoto").Preload("AdminGroups.Scopes")
|
2022-08-10 05:10:47 +00:00
|
|
|
}
|
|
|
|
|
2022-08-11 03:59:59 +00:00
|
|
|
// UserStatus options.
|
|
|
|
type UserStatus string
|
|
|
|
|
|
|
|
const (
|
|
|
|
UserStatusActive = "active"
|
|
|
|
UserStatusDisabled = "disabled"
|
2022-08-14 23:27:57 +00:00
|
|
|
UserStatusBanned = "banned"
|
2022-08-11 03:59:59 +00:00
|
|
|
)
|
|
|
|
|
2022-08-10 05:10:47 +00:00
|
|
|
// 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{
|
2022-08-22 00:29:39 +00:00
|
|
|
Username: username,
|
|
|
|
Email: email,
|
|
|
|
Status: UserStatusActive,
|
|
|
|
Visibility: UserVisibilityPublic,
|
2022-08-10 05:10:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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{}
|
2022-08-13 06:11:36 +00:00
|
|
|
result := user.Preload().First(&user, userId)
|
2022-08-10 05:10:47 +00:00
|
|
|
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.
|
2022-09-09 04:42:20 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-08-22 04:53:35 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2022-08-10 05:10:47 +00:00
|
|
|
// FindUser by username or email.
|
|
|
|
func FindUser(username string) (*User, error) {
|
2022-08-11 03:59:59 +00:00
|
|
|
if username == "" {
|
|
|
|
return nil, errors.New("username is required")
|
|
|
|
}
|
|
|
|
|
2022-08-10 05:10:47 +00:00
|
|
|
u := &User{}
|
|
|
|
if strings.ContainsRune(username, '@') {
|
2022-08-13 06:11:36 +00:00
|
|
|
result := u.Preload().Where("email = ?", username).Limit(1).First(u)
|
2022-08-10 05:10:47 +00:00
|
|
|
return u, result.Error
|
|
|
|
}
|
2022-08-13 06:11:36 +00:00
|
|
|
result := u.Preload().Where("username = ?", username).Limit(1).First(u)
|
2022-08-10 05:10:47 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-02-14 06:19:18 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2024-04-25 03:36:37 +00:00
|
|
|
// 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 {
|
2023-08-16 00:33:33 +00:00
|
|
|
Username string
|
|
|
|
Gender string
|
|
|
|
Orientation string
|
|
|
|
MaritalStatus string
|
2023-08-30 04:10:00 +00:00
|
|
|
HereFor string
|
2024-06-19 21:12:25 +00:00
|
|
|
ProfileText *Search
|
2024-08-03 21:54:22 +00:00
|
|
|
NearCity *WorldCities
|
2023-08-16 00:33:33 +00:00
|
|
|
Certified bool
|
2023-09-02 00:20:34 +00:00
|
|
|
NotCertified bool
|
2023-09-02 00:12:27 +00:00
|
|
|
ShyAccounts bool
|
2023-09-09 18:16:34 +00:00
|
|
|
IsBanned bool
|
2024-04-26 04:52:43 +00:00
|
|
|
IsDisabled bool
|
|
|
|
IsAdmin bool // search for admin users
|
2023-09-02 00:12:27 +00:00
|
|
|
Friends bool
|
2024-06-27 04:27:03 +00:00
|
|
|
Liked bool
|
2023-08-16 00:33:33 +00:00
|
|
|
AgeMin int
|
|
|
|
AgeMax int
|
2022-08-14 05:44:57 +00:00
|
|
|
}
|
|
|
|
|
2022-08-15 00:45:55 +00:00
|
|
|
// SearchUsers from the perspective of a given user.
|
2022-09-09 04:42:20 +00:00
|
|
|
func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, error) {
|
2022-08-14 05:44:57 +00:00
|
|
|
if search == nil {
|
|
|
|
search = &UserSearch{}
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
2022-08-15 00:45:55 +00:00
|
|
|
users = []*User{}
|
|
|
|
query *gorm.DB
|
2023-08-20 02:11:33 +00:00
|
|
|
joins string // GPS location join.
|
2022-08-15 00:45:55 +00:00
|
|
|
wheres = []string{}
|
|
|
|
placeholders = []interface{}{}
|
2023-10-22 22:02:24 +00:00
|
|
|
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.
|
2024-08-03 21:54:22 +00:00
|
|
|
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
|
|
|
}
|
|
|
|
|
2024-08-03 21:54:22 +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)`,
|
2024-08-03 21:54:22 +00:00
|
|
|
longitude, latitude,
|
2023-08-20 02:11:33 +00:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-08-15 00:45:55 +00:00
|
|
|
if len(blockedUserIDs) > 0 {
|
2024-08-24 06:34:12 +00:00
|
|
|
wheres = append(wheres, "users.id NOT IN ?")
|
2022-08-15 00:45:55 +00:00
|
|
|
placeholders = append(placeholders, blockedUserIDs)
|
|
|
|
}
|
|
|
|
|
2023-08-16 00:33:33 +00:00
|
|
|
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 ?)")
|
2023-09-30 22:24:14 +00:00
|
|
|
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
|
|
|
}
|
|
|
|
|
2024-06-19 21:12:25 +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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-26 04:52:43 +00:00
|
|
|
// Only admin user can show disabled/banned users.
|
|
|
|
var statuses = []string{}
|
2024-05-11 21:10:59 +00:00
|
|
|
if user.HasAdminScope(config.ScopeUserBan) {
|
2024-04-26 04:52:43 +00:00
|
|
|
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 ?")
|
2024-04-26 04:52:43 +00:00
|
|
|
placeholders = append(placeholders, statuses)
|
|
|
|
} else {
|
2024-08-24 06:09:27 +00:00
|
|
|
wheres = append(wheres, "users.status = ?")
|
2023-09-09 18:16:34 +00:00
|
|
|
placeholders = append(placeholders, UserStatusActive)
|
|
|
|
}
|
|
|
|
|
2023-09-02 00:12:27 +00:00
|
|
|
// 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 = ?")
|
2023-09-09 18:16:34 +00:00
|
|
|
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 = ?")
|
2023-09-09 18:16:34 +00:00
|
|
|
placeholders = append(placeholders, false)
|
2023-09-02 00:20:34 +00:00
|
|
|
}
|
|
|
|
|
2024-04-26 04:52:43 +00:00
|
|
|
if search.IsAdmin {
|
2024-08-24 06:09:27 +00:00
|
|
|
wheres = append(wheres, "users.is_admin = true")
|
2024-04-26 04:52:43 +00:00
|
|
|
}
|
|
|
|
|
2023-09-02 00:12:27 +00:00
|
|
|
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,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-08-14 21:40:57 +00:00
|
|
|
if search.AgeMin > 0 {
|
|
|
|
date := time.Now().AddDate(-search.AgeMin, 0, 0)
|
2023-10-08 17:57:08 +00:00
|
|
|
wheres = append(wheres, `
|
|
|
|
birthdate <= ? AND NOT EXISTS (
|
|
|
|
SELECT 1
|
|
|
|
FROM profile_fields
|
|
|
|
WHERE user_id = users.id
|
|
|
|
AND name = 'hide_age'
|
|
|
|
AND value = 'true'
|
|
|
|
)
|
|
|
|
`)
|
2022-08-14 21:40:57 +00:00
|
|
|
placeholders = append(placeholders, date)
|
|
|
|
}
|
|
|
|
|
|
|
|
if search.AgeMax > 0 {
|
|
|
|
date := time.Now().AddDate(-search.AgeMax-1, 0, 0)
|
2023-10-08 17:57:08 +00:00
|
|
|
wheres = append(wheres, `
|
|
|
|
birthdate >= ? AND NOT EXISTS (
|
|
|
|
SELECT 1
|
|
|
|
FROM profile_fields
|
|
|
|
WHERE user_id = users.id
|
|
|
|
AND name = 'hide_age'
|
|
|
|
AND value = 'true'
|
|
|
|
)
|
|
|
|
`)
|
2022-08-14 21:40:57 +00:00
|
|
|
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...,
|
2022-08-14 21:40:57 +00:00
|
|
|
).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)
|
2022-09-09 04:42:20 +00:00
|
|
|
|
|
|
|
// Inject relationship booleans.
|
|
|
|
SetUserRelationships(user, users)
|
|
|
|
|
2022-08-14 05:44:57 +00:00
|
|
|
return users, result.Error
|
|
|
|
}
|
|
|
|
|
2022-08-13 22:39:31 +00:00
|
|
|
// 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.
|
2022-09-09 04:42:20 +00:00
|
|
|
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)
|
|
|
|
}
|
2022-08-13 22:39:31 +00:00
|
|
|
|
|
|
|
var (
|
|
|
|
users = []*User{}
|
2022-08-14 00:42:42 +00:00
|
|
|
result = (&User{}).Preload().Where("id IN ?", distinct).Find(&users)
|
2022-08-13 22:39:31 +00:00
|
|
|
)
|
|
|
|
|
2022-09-09 04:42:20 +00:00
|
|
|
// Inject user relationships.
|
|
|
|
if user != nil {
|
|
|
|
SetUserRelationships(user, users)
|
|
|
|
}
|
|
|
|
|
2022-08-13 22:39:31 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2022-08-13 22:39:31 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
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 {
|
2024-01-07 22:20:01 +00:00
|
|
|
canSee, visibility := u.CanSeeProfilePicture(currentUser)
|
2024-06-30 04:42:35 +00:00
|
|
|
if canSee {
|
2024-01-07 22:20:01 +00:00
|
|
|
return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
|
|
|
|
}
|
|
|
|
|
|
|
|
switch visibility {
|
|
|
|
case PhotoPrivate:
|
2023-09-14 04:28:38 +00:00
|
|
|
return "/static/img/shy-private.png"
|
2024-01-07 22:20:01 +00:00
|
|
|
case PhotoFriends:
|
2023-09-14 04:28:38 +00:00
|
|
|
return "/static/img/shy-friends.png"
|
|
|
|
}
|
2024-01-07 22:20:01 +00:00
|
|
|
|
2023-09-14 04:28:38 +00:00
|
|
|
return "/static/img/shy.png"
|
|
|
|
}
|
|
|
|
|
2024-01-07 22:20:01 +00:00
|
|
|
// 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) {
|
2024-01-07 23:32:51 +00:00
|
|
|
if !u.UserRelationship.Computed && currentUser != nil {
|
2024-01-07 22:25:00 +00:00
|
|
|
SetUserRelationships(currentUser, []*User{u})
|
|
|
|
}
|
|
|
|
|
2024-01-07 22:20:01 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2022-08-10 05:10:47 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2024-06-15 22:05:50 +00:00
|
|
|
// 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()
|
|
|
|
}
|
|
|
|
|
2022-08-10 05:10:47 +00:00
|
|
|
// 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))
|
|
|
|
}
|
|
|
|
|
2022-08-11 03:59:59 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2023-06-24 05:18:09 +00:00
|
|
|
log.Debug("User(%s): append ProfileField %s", u.Username, name)
|
2022-08-11 03:59:59 +00:00
|
|
|
u.ProfileField = append(u.ProfileField, ProfileField{
|
|
|
|
Name: name,
|
|
|
|
Value: value,
|
|
|
|
})
|
2023-11-24 19:37:01 +00:00
|
|
|
if err := u.Save(); err != nil {
|
|
|
|
log.Error("User(%s).SetProfileField(%s): error saving after append new field: %s", u.Username, name, err)
|
|
|
|
}
|
2022-08-11 03:59:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2023-06-16 05:05:21 +00:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2022-08-11 03:59:59 +00:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2022-08-21 22:40:24 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2022-08-10 05:10:47 +00:00
|
|
|
// Save user.
|
|
|
|
func (u *User) Save() error {
|
|
|
|
result := DB.Save(u)
|
|
|
|
return result.Error
|
|
|
|
}
|
2022-08-13 06:11:36 +00:00
|
|
|
|
2022-08-14 21:40:57 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2022-08-13 06:11:36 +00:00
|
|
|
// 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()
|
|
|
|
}
|