Noah
6c91c67c97
* Users who set their Profile Picture to "friends only" or "private" can have their avatar be private all over the website to users who are not their friends or not granted access. * Users who are not your friends see a yellow placeholder avatar, and users not granted access to a private Profile Pic sees a purple avatar. * Admin users see these same placeholder avatars most places too (on search, forums, comments, etc.) if the user did not friend or grant the admin. But admins ALWAYS see it on their Profile Page directly, for ability to moderate. * Fix marking Notifications as read: clicking the link in an unread notification now will wait on the ajax request to finish before allowing the redirect. * Update the FAQ
388 lines
9.6 KiB
Go
388 lines
9.6 KiB
Go
package models
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.nonshy.com/nonshy/website/pkg/config"
|
|
"code.nonshy.com/nonshy/website/pkg/log"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// User account table.
|
|
type User struct {
|
|
ID uint64 `gorm:"primaryKey"`
|
|
Username string `gorm:"uniqueIndex"`
|
|
Email string `gorm:"uniqueIndex"`
|
|
HashedPassword string
|
|
IsAdmin bool `gorm:"index"`
|
|
Status UserStatus `gorm:"index"` // active, disabled
|
|
Visibility UserVisibility `gorm:"index"` // public, private
|
|
Name *string
|
|
Birthdate time.Time
|
|
Certified bool
|
|
Explicit bool // 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
|
|
ProfilePhotoID *uint64
|
|
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
|
|
|
|
// Current user's relationship to this user -- not stored in DB.
|
|
UserRelationship UserRelationship `gorm:"-"`
|
|
}
|
|
|
|
type UserVisibility string
|
|
|
|
const (
|
|
UserVisibilityPublic UserVisibility = "public"
|
|
UserVisibilityExternal = "external"
|
|
UserVisibilityPrivate = "private"
|
|
)
|
|
|
|
// All visibility options.
|
|
var UserVisibilityOptions = []UserVisibility{
|
|
UserVisibilityPublic,
|
|
UserVisibilityExternal,
|
|
UserVisibilityPrivate,
|
|
}
|
|
|
|
// Preload related tables for the user (classmethod).
|
|
func (u *User) Preload() *gorm.DB {
|
|
return DB.Preload("ProfileField").Preload("ProfilePhoto")
|
|
}
|
|
|
|
// UserStatus options.
|
|
type UserStatus string
|
|
|
|
const (
|
|
UserStatusActive = "active"
|
|
UserStatusDisabled = "disabled"
|
|
UserStatusBanned = "banned"
|
|
)
|
|
|
|
// CreateUser. It is assumed username and email are correctly formatted.
|
|
func CreateUser(username, email, password string) (*User, error) {
|
|
// Verify username and email are unique.
|
|
if _, err := FindUser(username); err == nil {
|
|
return nil, errors.New("That username already exists. Please try a different username.")
|
|
} else if _, err := FindUser(email); err == nil {
|
|
return nil, errors.New("That email address is already registered.")
|
|
}
|
|
|
|
u := &User{
|
|
Username: username,
|
|
Email: email,
|
|
Status: UserStatusActive,
|
|
Visibility: UserVisibilityPublic,
|
|
}
|
|
|
|
if err := u.HashPassword(password); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := DB.Create(u)
|
|
return u, result.Error
|
|
}
|
|
|
|
// GetUser by ID.
|
|
func GetUser(userId uint64) (*User, error) {
|
|
user := &User{}
|
|
result := user.Preload().First(&user, userId)
|
|
return user, result.Error
|
|
}
|
|
|
|
// GetUsers queries for multiple user IDs and returns users in the same order.
|
|
func GetUsers(currentUser *User, userIDs []uint64) ([]*User, error) {
|
|
userMap, err := MapUsers(currentUser, userIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Re-order them per the original sequence.
|
|
var users = []*User{}
|
|
for _, uid := range userIDs {
|
|
if user, ok := userMap[uid]; ok {
|
|
users = append(users, user)
|
|
}
|
|
}
|
|
|
|
return users, nil
|
|
}
|
|
|
|
// FindUser by username or email.
|
|
func FindUser(username string) (*User, error) {
|
|
if username == "" {
|
|
return nil, errors.New("username is required")
|
|
}
|
|
|
|
u := &User{}
|
|
if strings.ContainsRune(username, '@') {
|
|
result := u.Preload().Where("email = ?", username).Limit(1).First(u)
|
|
return u, result.Error
|
|
}
|
|
result := u.Preload().Where("username = ?", username).Limit(1).First(u)
|
|
return u, result.Error
|
|
}
|
|
|
|
// UserSearch config.
|
|
type UserSearch struct {
|
|
EmailOrUsername string
|
|
Gender string
|
|
Orientation string
|
|
MaritalStatus string
|
|
Certified bool
|
|
AgeMin int
|
|
AgeMax int
|
|
}
|
|
|
|
// SearchUsers from the perspective of a given user.
|
|
func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, error) {
|
|
if search == nil {
|
|
search = &UserSearch{}
|
|
}
|
|
|
|
var (
|
|
users = []*User{}
|
|
query *gorm.DB
|
|
wheres = []string{}
|
|
placeholders = []interface{}{}
|
|
blockedUserIDs = BlockedUserIDs(user.ID)
|
|
)
|
|
|
|
if len(blockedUserIDs) > 0 {
|
|
wheres = append(wheres, "id NOT IN ?")
|
|
placeholders = append(placeholders, blockedUserIDs)
|
|
}
|
|
|
|
if search.EmailOrUsername != "" {
|
|
ilike := "%" + strings.TrimSpace(strings.ToLower(search.EmailOrUsername)) + "%"
|
|
wheres = append(wheres, "(email LIKE ? OR username LIKE ?)")
|
|
placeholders = append(placeholders, ilike, ilike)
|
|
}
|
|
|
|
if search.Gender != "" {
|
|
wheres = append(wheres, `
|
|
EXISTS (
|
|
SELECT 1 FROM profile_fields
|
|
WHERE user_id = users.id AND name = ? AND value = ?
|
|
)
|
|
`)
|
|
placeholders = append(placeholders, "gender", search.Gender)
|
|
}
|
|
|
|
if search.Orientation != "" {
|
|
wheres = append(wheres, `
|
|
EXISTS (
|
|
SELECT 1 FROM profile_fields
|
|
WHERE user_id = users.id AND name = ? AND value = ?
|
|
)
|
|
`)
|
|
placeholders = append(placeholders, "orientation", search.Orientation)
|
|
}
|
|
|
|
if search.MaritalStatus != "" {
|
|
wheres = append(wheres, `
|
|
EXISTS (
|
|
SELECT 1 FROM profile_fields
|
|
WHERE user_id = users.id AND name = ? AND value = ?
|
|
)
|
|
`)
|
|
placeholders = append(placeholders, "marital_status", search.MaritalStatus)
|
|
}
|
|
|
|
if search.Certified {
|
|
wheres = append(wheres, "certified = ?")
|
|
placeholders = append(placeholders, search.Certified)
|
|
}
|
|
|
|
if search.AgeMin > 0 {
|
|
date := time.Now().AddDate(-search.AgeMin, 0, 0)
|
|
wheres = append(wheres, "birthdate <= ?")
|
|
placeholders = append(placeholders, date)
|
|
}
|
|
|
|
if search.AgeMax > 0 {
|
|
date := time.Now().AddDate(-search.AgeMax-1, 0, 0)
|
|
wheres = append(wheres, "birthdate >= ?")
|
|
placeholders = append(placeholders, date)
|
|
}
|
|
|
|
query = (&User{}).Preload().Where(
|
|
strings.Join(wheres, " AND "),
|
|
placeholders...,
|
|
).Order(pager.Sort)
|
|
query.Model(&User{}).Count(&pager.Total)
|
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users)
|
|
|
|
// Inject relationship booleans.
|
|
SetUserRelationships(user, users)
|
|
|
|
return users, result.Error
|
|
}
|
|
|
|
// UserMap helps map a set of users to look up by ID.
|
|
type UserMap map[uint64]*User
|
|
|
|
// MapUsers looks up a set of user IDs in bulk and returns a UserMap suitable for templates.
|
|
// Useful to avoid circular reference issues with Photos especially; the Site Gallery queries
|
|
// photos of ALL users and MapUsers helps stitch them together for the frontend.
|
|
func MapUsers(user *User, userIDs []uint64) (UserMap, error) {
|
|
var (
|
|
usermap = UserMap{}
|
|
set = map[uint64]interface{}{}
|
|
distinct = []uint64{}
|
|
)
|
|
|
|
// Uniqueify users.
|
|
for _, uid := range userIDs {
|
|
if _, ok := set[uid]; ok {
|
|
continue
|
|
}
|
|
set[uid] = nil
|
|
distinct = append(distinct, uid)
|
|
}
|
|
|
|
var (
|
|
users = []*User{}
|
|
result = (&User{}).Preload().Where("id IN ?", distinct).Find(&users)
|
|
)
|
|
|
|
// Inject user relationships.
|
|
if user != nil {
|
|
SetUserRelationships(user, users)
|
|
}
|
|
|
|
if result.Error == nil {
|
|
for _, row := range users {
|
|
usermap[row.ID] = row
|
|
}
|
|
}
|
|
|
|
return usermap, result.Error
|
|
}
|
|
|
|
// Has a user ID in the map?
|
|
func (um UserMap) Has(id uint64) bool {
|
|
_, ok := um[id]
|
|
return ok
|
|
}
|
|
|
|
// Get a user from the UserMap.
|
|
func (um UserMap) Get(id uint64) *User {
|
|
if user, ok := um[id]; ok {
|
|
return user
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NameOrUsername returns the name (if not null or empty) or the username.
|
|
func (u *User) NameOrUsername() string {
|
|
if u.Name != nil && len(*u.Name) > 0 {
|
|
return *u.Name
|
|
} else {
|
|
return u.Username
|
|
}
|
|
}
|
|
|
|
// HashPassword sets the user's hashed (bcrypt) password.
|
|
func (u *User) HashPassword(password string) error {
|
|
passwd, err := bcrypt.GenerateFromPassword([]byte(password), config.BcryptCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u.HashedPassword = string(passwd)
|
|
return nil
|
|
}
|
|
|
|
// CheckPassword verifies the password is correct. Returns nil on success.
|
|
func (u *User) CheckPassword(password string) error {
|
|
return bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password))
|
|
}
|
|
|
|
// SetProfileField sets or creates a named profile field.
|
|
func (u *User) SetProfileField(name, value string) {
|
|
// Check if it exists.
|
|
log.Debug("User(%s).SetProfileField(%s, %s)", u.Username, name, value)
|
|
var exists bool
|
|
for _, field := range u.ProfileField {
|
|
log.Debug("\tCheck existing field %s", field.Name)
|
|
if field.Name == name {
|
|
log.Debug("\tFound existing field!")
|
|
changed := field.Value != value
|
|
field.Value = value
|
|
exists = true
|
|
|
|
// Save it now. TODO: otherwise gorm doesn't know we changed
|
|
// it and it won't be inserted when the User is saved. But
|
|
// this is probably not performant to do!
|
|
if changed {
|
|
DB.Save(&field)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if exists {
|
|
return
|
|
}
|
|
|
|
u.ProfileField = append(u.ProfileField, ProfileField{
|
|
Name: name,
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
// GetProfileField returns the value of a profile field or blank string.
|
|
func (u *User) GetProfileField(name string) string {
|
|
for _, field := range u.ProfileField {
|
|
if field.Name == name {
|
|
return field.Value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ProfileFieldIn checks if a substring is IN a profile field. Currently
|
|
// does a naive strings.Contains(), intended for the "here_for" field.
|
|
func (u *User) ProfileFieldIn(field, substr string) bool {
|
|
value := u.GetProfileField(field)
|
|
return strings.Contains(value, substr)
|
|
}
|
|
|
|
// RemoveProfilePhoto sets profile_photo_id=null to unset the foreign key.
|
|
func (u *User) RemoveProfilePhoto() error {
|
|
result := DB.Model(&User{}).Where("id = ?", u.ID).Update("profile_photo_id", nil)
|
|
return result.Error
|
|
}
|
|
|
|
// Save user.
|
|
func (u *User) Save() error {
|
|
result := DB.Save(u)
|
|
return result.Error
|
|
}
|
|
|
|
// Delete a user. NOTE: use the models/deletion/DeleteUser() function
|
|
// instead of this to do a deep scrub of all related data!
|
|
func (u *User) Delete() error {
|
|
return DB.Delete(u).Error
|
|
}
|
|
|
|
// Print user object as pretty JSON.
|
|
func (u *User) Print() string {
|
|
var (
|
|
buf bytes.Buffer
|
|
enc = json.NewEncoder(&buf)
|
|
)
|
|
enc.SetIndent("", " ")
|
|
enc.Encode(u)
|
|
return buf.String()
|
|
}
|