website/pkg/models/photo.go
2023-10-26 17:33:08 -07:00

575 lines
15 KiB
Go

package models
import (
"errors"
"fmt"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm"
)
// Photo table.
type Photo struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"`
Filename string
CroppedFilename string // if cropped, e.g. for profile photo
Filesize int64
Caption string
Flagged bool // photo has been reported by the community
Visibility PhotoVisibility
Gallery bool // photo appears in the public gallery (if public)
Explicit bool // is an explicit photo
CreatedAt time.Time
UpdatedAt time.Time
}
// PhotoVisibility settings.
type PhotoVisibility string
const (
PhotoPublic PhotoVisibility = "public" // on profile page and/or public gallery
PhotoFriends PhotoVisibility = "friends" // only friends can see it
PhotoPrivate PhotoVisibility = "private" // private
PhotoInnerCircle PhotoVisibility = "circle" // inner circle
// Special visibility in case, on User Gallery view, user applies a filter
// for friends-only picture but they are not friends with the user.
PhotoNotAvailable PhotoVisibility = "not_available"
)
// PhotoVisibility preset settings.
var (
PhotoVisibilityAll = []PhotoVisibility{
PhotoPublic,
PhotoFriends,
PhotoPrivate,
}
// "All" but also for Inner Circle members.
PhotoVisibilityCircle = []PhotoVisibility{
PhotoPublic,
PhotoFriends,
PhotoPrivate,
PhotoInnerCircle,
}
// Site Gallery visibility for when your friends show up in the gallery.
// Or: "Friends + Gallery" photos can appear to your friends in the Site Gallery.
PhotoVisibilityFriends = []string{
string(PhotoPublic),
string(PhotoFriends),
}
)
// CreatePhoto with most of the settings you want (not ID or timestamps) in the database.
func CreatePhoto(tmpl Photo) (*Photo, error) {
if tmpl.UserID == 0 {
return nil, errors.New("UserID required")
}
p := &Photo{
UserID: tmpl.UserID,
Filename: tmpl.Filename,
CroppedFilename: tmpl.CroppedFilename,
Filesize: tmpl.Filesize,
Caption: tmpl.Caption,
Visibility: tmpl.Visibility,
Gallery: tmpl.Gallery,
Explicit: tmpl.Explicit,
}
result := DB.Create(p)
return p, result.Error
}
// GetPhoto by ID.
func GetPhoto(id uint64) (*Photo, error) {
p := &Photo{}
result := DB.First(&p, id)
return p, result.Error
}
// GetPhotos by an array of IDs, mapped to their IDs.
func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) {
var (
mp = map[uint64]*Photo{}
ps = []*Photo{}
)
result := DB.Model(&Photo{}).Where("id IN ?", IDs).Find(&ps)
for _, row := range ps {
mp[row.ID] = row
}
return mp, result.Error
}
// UserGallery configuration for filtering gallery pages.
type UserGallery struct {
Explicit string // "", "true", "false"
Visibility []PhotoVisibility
}
/*
PaginateUserPhotos gets a page of photos belonging to a user ID.
*/
func PaginateUserPhotos(userID uint64, conf UserGallery, pager *Pagination) ([]*Photo, error) {
var (
p = []*Photo{}
wheres = []string{}
placeholders = []interface{}{}
)
var explicit = []bool{}
switch conf.Explicit {
case "true":
explicit = []bool{true}
case "false":
explicit = []bool{false}
}
wheres = append(wheres, "user_id = ? AND visibility IN ?")
placeholders = append(placeholders, userID, conf.Visibility)
if len(explicit) > 0 {
wheres = append(wheres, "explicit = ?")
placeholders = append(placeholders, explicit[0])
}
query := DB.Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(
pager.Sort,
)
// Get the total count.
query.Model(&Photo{}).Count(&pager.Total)
result := query.Offset(
pager.GetOffset(),
).Limit(pager.PerPage).Find(&p)
return p, result.Error
}
// CountPhotos returns the total number of photos on a user's account.
func CountPhotos(userID uint64) int64 {
var count int64
result := DB.Where(
"user_id = ?",
userID,
).Model(&Photo{}).Count(&count)
if result.Error != nil {
log.Error("CountPhotos(%d): %s", userID, result.Error)
}
return count
}
// CountPhotosICanSee returns the number of photos on an account which can be seen by the given viewer.
func CountPhotosICanSee(user *User, viewer *User) int64 {
// Visibility filters to query by.
var visibilities = []PhotoVisibility{
PhotoPublic,
}
// Is the viewer in the inner circle?
if viewer.IsInnerCircle() {
visibilities = append(visibilities, PhotoInnerCircle)
}
// Is the viewer friends with the target?
if AreFriends(user.ID, viewer.ID) {
visibilities = append(visibilities, PhotoFriends)
}
// Is the viewer granted private access?
if IsPrivateUnlocked(user.ID, viewer.ID) {
visibilities = append(visibilities, PhotoPrivate)
}
// Get the photo count now.
var count int64
result := DB.Where(
"user_id = ? AND visibility IN ?",
user.ID, visibilities,
).Model(&Photo{}).Count(&count)
if result.Error != nil {
log.Error("CountPhotosICanSee(%d, %d): %s", user.ID, viewer.ID, result.Error)
}
return count
}
// MapPhotoCounts returns a mapping of user ID to the CountPhotos()-equivalent result for each.
// It's used on the member directory to show photo counts on each user card.
func MapPhotoCounts(users []*User) PhotoCountMap {
var (
userIDs = []uint64{}
result = PhotoCountMap{}
)
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
type group struct {
UserID uint64
PhotoCount int64
}
var groups = []group{}
if res := DB.Table(
"photos",
).Select(
"user_id, count(id) AS photo_count",
).Where(
"user_id IN ? AND visibility = ?", userIDs, PhotoPublic,
).Group("user_id").Scan(&groups); res.Error != nil {
log.Error("CountPhotosForUsers: %s", res.Error)
}
// Map the results in.
for _, row := range groups {
result[row.UserID] = row.PhotoCount
}
return result
}
// MapPhotoCounts returns a mapping of user ID to the CountPhotosICanSee()-equivalent result for each.
// It's used on the member directory to show photo counts on each user card.
/* TODO: under construction..
func MapPhotoCounts(users []*User, viewer *User) PhotoCountMap {
var (
userIDs = []uint64{}
result = PhotoCountMap{}
wheres = []string{}
placeholders = []interface{}{}
// User ID filters for the viewer's context.
myFriendIDs = FriendIDs(viewer.ID)
myPrivateGrantedIDs = PrivateGrantedUserIDs(viewer.ID)
isInnerCircle = viewer.IsInnerCircle()
)
// Define "all photos visibilities"
var (
photosPublic = []PhotoVisibility{
PhotoPublic,
}
photosFriends = []PhotoVisibility{
PhotoPublic,
PhotoFriends,
}
photosPrivate = []PhotoVisibility{
PhotoPublic,
PhotoPrivate,
}
)
if isInnerCircle {
photosPublic = append(photosPublic, PhotoInnerCircle)
photosFriends = append(photosFriends, PhotoInnerCircle)
}
// Flatten the userIDs of all passed in users.
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
// Build the where clause.
wheres = append(wheres, "user_id IN ?")
placeholders = append(placeholders, userIDs)
log.Error("FRIEND IDS: %+v", myFriendIDs)
// Filter by which photos are visible to us.
wheres = append(wheres,
"((user_id IN ? AND visibility IN ?) OR "+
"(user_id IN ? AND visibility IN ?) OR "+
"(user_id NOT IN ? AND visibility IN ?))",
)
placeholders = append(placeholders,
myFriendIDs, photosFriends,
myPrivateGrantedIDs, photosPrivate,
myFriendIDs, photosPublic,
)
type group struct {
UserID uint64
PhotoCount int64
}
var groups = []group{}
if res := DB.Table(
"photos",
).Select(
"user_id, count(id) AS photo_count",
).Where(
strings.Join(wheres, " AND "),
placeholders...,
).Group("user_id").Scan(&groups); res.Error != nil {
log.Error("CountPhotosForUsers: %s", res.Error)
}
// Map the results in.
for _, row := range groups {
result[row.UserID] = row.PhotoCount
}
return result
}
*/
type PhotoCountMap map[uint64]int64
// Get a photo count for the given user ID from the map.
func (pc PhotoCountMap) Get(id uint64) int64 {
if value, ok := pc[id]; ok {
return value
}
return 0
}
// CountExplicitPhotos returns the number of explicit photos a user has (so non-explicit viewers can see some do exist)
func CountExplicitPhotos(userID uint64, visibility []PhotoVisibility) (int64, error) {
query := DB.Where(
"user_id = ? AND visibility IN ? AND explicit = ?",
userID,
visibility,
true,
)
var count int64
result := query.Model(&Photo{}).Count(&count)
return count, result.Error
}
// CountPublicPhotos returns the number of public photos on a user's page (to control whether the "invite to circle" prompt can appear).
func CountPublicPhotos(userID uint64) int64 {
query := DB.Where(
"user_id = ? AND visibility = ?",
userID,
PhotoPublic,
)
var count int64
result := query.Model(&Photo{}).Count(&count)
if result.Error != nil {
log.Error("CountPublicPhotos(%d): %s", userID, result.Error)
}
return count
}
// DistinctPhotoTypes returns types of photos the user has: a set of public, friends, or private.
//
// The result is cached on the User the first time it's queried.
func (u *User) DistinctPhotoTypes() (result map[PhotoVisibility]struct{}) {
if u.cachePhotoTypes != nil {
return u.cachePhotoTypes
}
result = map[PhotoVisibility]struct{}{}
var results = []*Photo{}
query := DB.Model(&Photo{}).
Select("DISTINCT photos.visibility").
Where("user_id = ?", u.ID).
Group("photos.visibility").
Find(&results)
if query.Error != nil {
log.Error("User.DistinctPhotoTypes(%s): %s", u.Username, query.Error)
return
}
for _, row := range results {
log.Warn("DistinctPhotoTypes(%s): got %+v", u.Username, row)
result[row.Visibility] = struct{}{}
}
u.cachePhotoTypes = result
return
}
// Gallery config for the main Gallery paginator.
type Gallery struct {
Explicit string // Explicit filter
Visibility string // Visibility filter
AdminView bool // Show all images
IsShy bool // Current user is like a Shy Account (or: show self/friends and private photo grants only)
FriendsOnly bool // Only show self/friends instead of everybody's pics
}
/*
PaginateGalleryPhotos gets a page of all public user photos for the site gallery.
Admin view returns ALL photos regardless of Gallery status.
*/
func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Photo, error) {
var (
filterExplicit = conf.Explicit
filterVisibility = conf.Visibility
adminView = conf.AdminView
friendsOnly = conf.FriendsOnly // Show only self and friends pictures
isShy = conf.IsShy // Self, friends, and private photo grants only
p = []*Photo{}
query *gorm.DB
// Get the user ID and their preferences.
userID = user.ID
explicitOK = user.Explicit // User opted-in for explicit content
blocklist = BlockedUserIDs(user)
friendIDs = FriendIDs(userID)
privateUserIDs = PrivateGrantedUserIDs(userID)
privateUserIDsAreFriends = PrivateGrantedUserIDsAreFriends(user)
wheres = []string{}
placeholders = []interface{}{}
)
// Define "all photos visibilities"
var (
photosPublic = []PhotoVisibility{
PhotoPublic,
}
photosFriends = []PhotoVisibility{
PhotoPublic,
PhotoFriends,
}
photosPrivate = []PhotoVisibility{
PhotoPrivate,
}
)
if user.IsInnerCircle() {
photosPublic = append(photosPublic, PhotoInnerCircle)
photosFriends = append(photosFriends, PhotoInnerCircle)
}
// Admins see everything on the site (only an admin user can get an admin view).
adminView = user.HasAdminScope(config.ScopePhotoModerator) && adminView
// Include ourself in our friend IDs.
friendIDs = append(friendIDs, userID)
// What sets of User ID * Visibility filters to query under?
var (
visOrs = []string{}
visPlaceholders = []interface{}{}
)
// Whose photos can you see on the Site Gallery?
if isShy {
// Shy users can only see their Friends photos (public or friends visibility)
// and any Private photos to whom they were granted access.
visOrs = append(visOrs,
"(user_id IN ? AND visibility IN ?)",
"(user_id IN ? AND visibility IN ?)",
)
visPlaceholders = append(visPlaceholders,
friendIDs, photosFriends,
privateUserIDs, photosPrivate,
)
} else if friendsOnly {
// User wants to see only self and friends photos.
visOrs = append(visOrs, "(user_id IN ? AND visibility IN ?)")
visPlaceholders = append(visPlaceholders, friendIDs, photosFriends)
// If their friends granted private photos, include those too.
if len(privateUserIDsAreFriends) > 0 {
visOrs = append(visOrs, "(user_id IN ? AND visibility IN ?)")
visPlaceholders = append(visPlaceholders, privateUserIDsAreFriends, photosPrivate)
}
} else {
// You can see friends' Friend photos but only public for non-friends.
visOrs = append(visOrs,
"(user_id IN ? AND visibility IN ?)",
"(user_id IN ? AND visibility IN ?)",
"(user_id NOT IN ? AND visibility IN ?)",
)
visPlaceholders = append(placeholders,
friendIDs, photosFriends,
privateUserIDs, photosPrivate,
friendIDs, photosPublic,
)
}
// Join the User ID * Visibility filters into a nested "OR"
wheres = append(wheres, fmt.Sprintf("(%s)", strings.Join(visOrs, " OR ")))
placeholders = append(placeholders, visPlaceholders...)
// Gallery photos only.
wheres = append(wheres, "gallery = ?")
placeholders = append(placeholders, true)
// Filter blocked users.
if len(blocklist) > 0 {
wheres = append(wheres, "user_id NOT IN ?")
placeholders = append(placeholders, blocklist)
}
// Non-explicit pics unless the user opted in. Allow explicit filter setting to override.
if filterExplicit != "" {
wheres = append(wheres, "explicit = ?")
placeholders = append(placeholders, filterExplicit == "true")
} else if !explicitOK {
wheres = append(wheres, "explicit = ?")
placeholders = append(placeholders, false)
}
// Is the user furthermore clamping the visibility filter?
if filterVisibility != "" {
wheres = append(wheres, "visibility = ?")
placeholders = append(placeholders, filterVisibility)
}
// Only certified (and not banned) user photos.
wheres = append(wheres,
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true AND status='active')",
)
// Exclude private users' photos.
wheres = append(wheres,
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND visibility = 'private')",
)
// Admin view: get ALL PHOTOS on the site, period.
if adminView {
query = DB
// Admin may filter too.
if filterVisibility != "" {
query = query.Where("visibility = ?", filterVisibility)
}
if filterExplicit != "" {
query = query.Where("explicit = ?", filterExplicit == "true")
}
} else {
query = DB.Where(
strings.Join(wheres, " AND "),
placeholders...,
)
}
query = query.Order(pager.Sort)
query.Model(&Photo{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&p)
return p, result.Error
}
// Save photo.
func (p *Photo) Save() error {
result := DB.Save(p)
return result.Error
}
// Delete photo.
func (p *Photo) Delete() error {
result := DB.Delete(p)
return result.Error
}