website/pkg/models/user.go
Noah Petherbridge 4f04323d5a Public Avatar Consent Page
The nonshy website is changing the policy on profile pictures. From August 30,
the square cropped avatar images will need to be publicly viewable to everyone.

This implements the first pass of the rollout:

* Add the Public Avatar Consent Page which explains the change to users and
  asks for their acknowledgement. The link is available from their User Settings
  page, near their Certification Photo link.
* When users (with non-public avatars) accept the change: their square cropped
  avatar will become visible to everybody, instead of showing a placeholder
  avatar.
* Users can change their mind and opt back out, which will again show the
  placeholder avatar.
* The Certification Required middleware will automatically enforce the consent
  page once the scheduled go-live date arrives.

Next steps are:

1. Post an announcement on the forum about the upcoming change and link users
   to the consent form if they want to check it out early.
2. Update the nonshy site to add banners to places like the User Dashboard for
   users who will be affected by the change, to link them to the forum post
   and the consent page.
2024-06-29 16:44:18 -07:00

827 lines
22 KiB
Go

package models
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"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 `json:"-"`
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 `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 `json:"-"`
ProfilePhotoID *uint64
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;" json:"-"`
// 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 {
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
}
// 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
}
// 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
}
// 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
}
// 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
}
// 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
}
// UserSearch config.
type UserSearch struct {
Username string
Gender string
Orientation string
MaritalStatus string
HereFor string
ProfileText *Search
Certified bool
NotCertified bool
InnerCircle bool
ShyAccounts bool
IsBanned bool
IsDisabled bool
IsAdmin bool // search for admin users
Friends bool
Liked 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
joins string // GPS location join.
wheres = []string{}
placeholders = []interface{}{}
blockedUserIDs = BlockedUserIDs(user)
myLocation = GetUserLocation(user.ID)
)
// 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 sort members 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)) + "%"
wheres = append(wheres, "(username LIKE ? OR name ILIKE ?)")
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.HereFor != "" {
wheres = append(wheres, `
EXISTS (
SELECT 1 FROM profile_fields
WHERE user_id = users.id AND name = ? AND value LIKE ?
)
`)
placeholders = append(placeholders, "here_for", "%"+search.HereFor+"%")
}
// 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)
}
}
// Only admin user can show disabled/banned users.
var statuses = []string{}
if user.HasAdminScope(config.ScopeUserBan) {
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 {
wheres = append(wheres, "status IN ?")
placeholders = append(placeholders, statuses)
} else {
wheres = append(wheres, "status = ?")
placeholders = append(placeholders, UserStatusActive)
}
// Certified filter (including if Shy Accounts are asked for)
if search.Certified {
wheres = append(wheres, "certified = ?")
placeholders = append(placeholders, search.Certified)
}
// Expressly Not Certified filtering
if search.NotCertified {
wheres = append(wheres, "certified = ?")
placeholders = append(placeholders, false)
}
if search.IsAdmin {
wheres = append(wheres, "is_admin = true")
}
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.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,
)
}
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)
}
query = (&User{}).Preload()
if joins != "" {
query = query.Joins(joins)
}
query = query.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
}
// 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)
}
// 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
}
// 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
// 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
}
}
// 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 {
// Can the viewer see the picture based on its visibility setting?
canSee, visibility := u.CanSeeProfilePicture(currentUser)
// Or has the owner consented on the Public Avatar policy?
consent := u.GetProfileField("public_avatar_consent") == "true"
if canSee || consent {
return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
}
switch visibility {
case PhotoPrivate:
return "/static/img/shy-private.png"
case PhotoInnerCircle:
return "/static/img/shy-secret.png"
case PhotoFriends:
return "/static/img/shy-friends.png"
}
return "/static/img/shy.png"
}
// 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) {
if !u.UserRelationship.Computed && currentUser != nil {
SetUserRelationships(currentUser, []*User{u})
}
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 visibility == PhotoInnerCircle && currentUser != nil && !currentUser.IsInnerCircle() {
// Inner circle only
return false, visibility
} else if u.ProfilePhoto.CroppedFilename != "" {
// Happy path
return true, visibility
}
return false, PhotoPublic
}
// 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
}
// 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()
}
// 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,
})
if err := u.Save(); err != nil {
log.Error("User(%s).SetProfileField(%s): error saving after append new field: %s", u.Username, name, err)
}
}
// 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 ""
}
// 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"
}
// 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()))
startDate = now.Add(-6 * time.Hour)
endDate = now.Add(60 * time.Hour)
)
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()
}