website/pkg/models/user.go
Noah 8419958b25 Likes on Comments, and other minor improvements
* Add "Like" buttons to comments and forum posts.
* Make "private" profiles more private (logged-in users see only their profile
  pic, display name, and can friend request or message, if they are not approved
  friends of the private user)
* Add "logged-out view" visibility setting to profiles: to share a link to your
  page on other sites. Opt-in setting - default is login required to view your
  public profile page.
* CSRF cookie fix.
* Updated FAQ & Privacy pages.
2022-08-29 20:00:15 -07:00

376 lines
9.3 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"`
}
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(userIDs []uint64) ([]*User, error) {
userMap, err := MapUsers(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(userID uint64, search *UserSearch, pager *Pagination) ([]*User, error) {
if search == nil {
search = &UserSearch{}
}
var (
users = []*User{}
query *gorm.DB
wheres = []string{}
placeholders = []interface{}{}
blockedUserIDs = BlockedUserIDs(userID)
)
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)
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(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)
)
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()
}