website/pkg/models/user.go

663 lines
17 KiB
Go
Raw 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"`
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
2023-05-24 03:04:17 +00:00
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
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;"`
// 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
}
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
}
// 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
}
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
Certified bool
2023-09-02 00:20:34 +00:00
NotCertified bool
InnerCircle bool
ShyAccounts bool
IsBanned bool
Friends 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" {
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 order by distance because your location is not known")
}
// 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)`,
myLocation.Longitude, myLocation.Latitude,
)
}
if len(blockedUserIDs) > 0 {
wheres = append(wheres, "id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
if search.Username != "" {
ilike := "%" + strings.TrimSpace(strings.ToLower(search.Username)) + "%"
2023-10-11 01:03:05 +00:00
wheres = append(wheres, "(username LIKE ? OR 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
}
// All user searches will show active accounts only, unless we are admin.
if user.IsAdmin && search.IsBanned {
wheres = append(wheres, "status IN ?")
placeholders = append(placeholders, []string{
UserStatusBanned,
UserStatusDisabled,
})
} else if !user.IsAdmin {
wheres = append(wheres, "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 {
wheres = append(wheres, "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 {
wheres = append(wheres, "certified = ?")
placeholders = append(placeholders, false)
2023-09-02 00:20:34 +00:00
}
2023-05-24 03:04:17 +00:00
if search.InnerCircle {
wheres = append(wheres, "inner_circle = ? OR is_admin = ?")
placeholders = append(placeholders, true, 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.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)
}
2023-08-20 02:11:33 +00:00
query = (&User{}).Preload()
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
}
// 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 {
if u.ProfilePhoto.Visibility == PhotoPrivate && !u.UserRelationship.IsPrivateGranted {
return "/static/img/shy-private.png"
} else if u.ProfilePhoto.Visibility == PhotoFriends && !u.UserRelationship.IsFriend {
return "/static/img/shy-friends.png"
} else if u.ProfilePhoto.CroppedFilename != "" {
return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
}
return "/static/img/shy.png"
}
// 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
}
log.Debug("User(%s): append ProfileField %s", u.Username, name)
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 ""
}
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()
}