website/pkg/models/user.go
Noah 6c91c67c97 More Private User Avatars
* 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
2022-09-08 21:42:20 -07:00

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()
}