Private Photo Sharing
* Add ability to unlock your private photos for others. * Link to unlock is on another user's Photos page. * You can also access your "Manage Private Photos" page in the User Menu or Dashboard and add a user by username. * See who you have granted access, and see users who have granted you access to their private photos. * Private photos of users who unlocked them for you may appear on the Site Gallery if the photo allows. * Add new filters to the Site Gallery: you can choose to filter by Explicit, limit to a specific Visibility, and order by Newest or Oldest. Non-explicit users do not get a filter to see explicit content - they must opt-in in their settings. * Bugfix: Site Gallery was not correctly filtering Explicit photos away from users who did not opt-in for explicit!
This commit is contained in:
parent
31712eba4a
commit
de9ba94dd9
|
@ -12,6 +12,7 @@ var (
|
|||
PageSizeMemberSearch = 60
|
||||
PageSizeFriends = 12
|
||||
PageSizeBlockList = 12
|
||||
PageSizePrivatePhotoGrantees = 12
|
||||
PageSizeAdminCertification = 20
|
||||
PageSizeAdminFeedback = 20
|
||||
PageSizeSiteGallery = 16
|
||||
|
|
157
pkg/controller/photo/private.go
Normal file
157
pkg/controller/photo/private.go
Normal file
|
@ -0,0 +1,157 @@
|
|||
package photo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Private controller (/photo/private) to see and modify your Private Photo grants.
|
||||
func Private() http.HandlerFunc {
|
||||
// Reuse the upload page but with an EditPhoto variable.
|
||||
tmpl := templates.Must("photo/private.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
view = r.FormValue("view")
|
||||
isGrantee = view == "grantee"
|
||||
)
|
||||
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the users.
|
||||
pager := &models.Pagination{
|
||||
PerPage: config.PageSizePrivatePhotoGrantees,
|
||||
Sort: "updated_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
users, err := models.PaginatePrivatePhotoList(currentUser.ID, isGrantee, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate users: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"IsGrantee": isGrantee,
|
||||
"CountGrantee": models.CountPrivateGrantee(currentUser.ID),
|
||||
"Users": users,
|
||||
"Pager": pager,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Share your private photos with a new user.
|
||||
func Share() http.HandlerFunc {
|
||||
tmpl := templates.Must("photo/share.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// To whom?
|
||||
var (
|
||||
user *models.User
|
||||
username = r.FormValue("to")
|
||||
isRevokeAll = r.FormValue("intent") == "revoke-all"
|
||||
)
|
||||
|
||||
if username != "" {
|
||||
if u, err := models.FindUser(username); err != nil {
|
||||
session.FlashError(w, r, "That username was not found, please try again.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
} else {
|
||||
user = u
|
||||
}
|
||||
}
|
||||
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Are we revoking our privates from ALL USERS?
|
||||
if isRevokeAll {
|
||||
models.RevokePrivatePhotosAll(currentUser.ID)
|
||||
session.Flash(w, r, "Your private photos have been locked from ALL users.")
|
||||
templates.Redirect(w, "/photo/private")
|
||||
|
||||
// Remove ALL notifications sent to ALL users who had access before.
|
||||
models.RemoveNotification("__private_photos", currentUser.ID)
|
||||
return
|
||||
}
|
||||
|
||||
if user != nil && currentUser.ID == user.ID {
|
||||
session.FlashError(w, r, "You cannot share your private photos with yourself.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Any blocking?
|
||||
if user != nil && models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
|
||||
session.FlashError(w, r, "You are blocked from contacting this user.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// POSTing?
|
||||
if r.Method == http.MethodPost {
|
||||
var (
|
||||
intent = r.PostFormValue("intent")
|
||||
)
|
||||
|
||||
// If submitting, do it and redirect.
|
||||
if intent == "submit" {
|
||||
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
|
||||
session.Flash(w, r, "Your private photos have been unlocked for %s.", user.Username)
|
||||
templates.Redirect(w, "/photo/private")
|
||||
|
||||
// Create a notification for this.
|
||||
notif := &models.Notification{
|
||||
UserID: user.ID,
|
||||
AboutUser: *currentUser,
|
||||
Type: models.NotificationPrivatePhoto,
|
||||
TableName: "__private_photos",
|
||||
TableID: currentUser.ID,
|
||||
Link: fmt.Sprintf("/photo/u/%s", currentUser.Username),
|
||||
}
|
||||
if err := models.CreateNotification(notif); err != nil {
|
||||
log.Error("Couldn't create PrivatePhoto notification: %s", err)
|
||||
}
|
||||
|
||||
return
|
||||
} else if intent == "revoke" {
|
||||
models.RevokePrivatePhotos(currentUser.ID, user.ID)
|
||||
session.Flash(w, r, "You have revoked access to your private photos for %s.", user.Username)
|
||||
templates.Redirect(w, "/photo/private")
|
||||
|
||||
// Remove any notification we created when the grant was given.
|
||||
models.RemoveSpecificNotification(user.ID, models.NotificationPrivatePhoto, "__private_photos", currentUser.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// The other intent is "preview" so the user gets the confirmation
|
||||
// screen before they continue, which shows the selected user info.
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"User": user,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -12,11 +12,37 @@ import (
|
|||
// SiteGallery controller (/photo/gallery) to view all members' public gallery pics.
|
||||
func SiteGallery() http.HandlerFunc {
|
||||
tmpl := templates.Must("photo/gallery.html")
|
||||
|
||||
// Whitelist for ordering options.
|
||||
var sortWhitelist = []string{
|
||||
"created_at desc",
|
||||
"created_at asc",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query params.
|
||||
var (
|
||||
viewStyle = r.FormValue("view") // cards (default), full
|
||||
|
||||
// Search filters.
|
||||
filterExplicit = r.FormValue("explicit")
|
||||
filterVisibility = r.FormValue("visibility")
|
||||
sort = r.FormValue("sort")
|
||||
sortOK bool
|
||||
)
|
||||
|
||||
// Sort options.
|
||||
for _, v := range sortWhitelist {
|
||||
if sort == v {
|
||||
sortOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sortOK {
|
||||
sort = "created_at desc"
|
||||
}
|
||||
|
||||
// Defaults.
|
||||
if viewStyle != "full" {
|
||||
viewStyle = "cards"
|
||||
}
|
||||
|
@ -31,10 +57,10 @@ func SiteGallery() http.HandlerFunc {
|
|||
pager := &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeSiteGallery,
|
||||
Sort: "created_at desc",
|
||||
Sort: sort,
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
photos, err := models.PaginateGalleryPhotos(currentUser.ID, currentUser.IsAdmin, currentUser.Explicit, pager)
|
||||
photos, err := models.PaginateGalleryPhotos(currentUser, filterExplicit, filterVisibility, pager)
|
||||
|
||||
// Bulk load the users associated with these photos.
|
||||
var userIDs = []uint64{}
|
||||
|
@ -62,6 +88,11 @@ func SiteGallery() http.HandlerFunc {
|
|||
"CommentMap": commentMap,
|
||||
"Pager": pager,
|
||||
"ViewStyle": viewStyle,
|
||||
|
||||
// Search filters
|
||||
"Sort": sort,
|
||||
"FilterExplicit": filterExplicit,
|
||||
"FilterVisibility": filterVisibility,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
|
@ -62,9 +62,15 @@ func UserPhotos() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Has this user granted access to see their privates?
|
||||
var (
|
||||
isGrantee = models.IsPrivateUnlocked(user.ID, currentUser.ID) // THEY have granted US access
|
||||
isGranted = models.IsPrivateUnlocked(currentUser.ID, user.ID) // WE have granted THEM access
|
||||
)
|
||||
|
||||
// What set of visibilities to query?
|
||||
visibility := []models.PhotoVisibility{models.PhotoPublic}
|
||||
if isOwnPhotos || currentUser.IsAdmin {
|
||||
if isOwnPhotos || isGrantee || currentUser.IsAdmin {
|
||||
visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate)
|
||||
} else if models.AreFriends(user.ID, currentUser.ID) {
|
||||
visibility = append(visibility, models.PhotoFriends)
|
||||
|
@ -100,15 +106,16 @@ func UserPhotos() http.HandlerFunc {
|
|||
commentMap := models.MapCommentCounts("photos", photoIDs)
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"IsOwnPhotos": currentUser.ID == user.ID,
|
||||
"User": user,
|
||||
"Photos": photos,
|
||||
"PhotoCount": models.CountPhotos(user.ID),
|
||||
"Pager": pager,
|
||||
"LikeMap": likeMap,
|
||||
"CommentMap": commentMap,
|
||||
"ViewStyle": viewStyle,
|
||||
"ExplicitCount": explicitCount,
|
||||
"IsOwnPhotos": currentUser.ID == user.ID,
|
||||
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
|
||||
"User": user,
|
||||
"Photos": photos,
|
||||
"PhotoCount": models.CountPhotos(user.ID),
|
||||
"Pager": pager,
|
||||
"LikeMap": likeMap,
|
||||
"CommentMap": commentMap,
|
||||
"ViewStyle": viewStyle,
|
||||
"ExplicitCount": explicitCount,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
|
@ -66,6 +66,13 @@ func View() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Is this a private photo and are we allowed to see?
|
||||
isGranted := models.IsPrivateUnlocked(user.ID, currentUser.ID)
|
||||
if !isGranted && !currentUser.IsAdmin {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get Likes information about these photos.
|
||||
likeMap := models.MapLikes(currentUser, "photos", []uint64{photo.ID})
|
||||
commentMap := models.MapCommentCounts("photos", []uint64{photo.ID})
|
||||
|
|
|
@ -11,6 +11,7 @@ func AutoMigrate() {
|
|||
DB.AutoMigrate(&User{})
|
||||
DB.AutoMigrate(&ProfileField{})
|
||||
DB.AutoMigrate(&Photo{})
|
||||
DB.AutoMigrate(&PrivatePhoto{})
|
||||
DB.AutoMigrate(&CertificationPhoto{})
|
||||
DB.AutoMigrate(&Message{})
|
||||
DB.AutoMigrate(&Friend{})
|
||||
|
|
|
@ -38,6 +38,7 @@ const (
|
|||
NotificationAlsoPosted = "also_posted" // forum replies
|
||||
NotificationCertRejected = "cert_rejected"
|
||||
NotificationCertApproved = "cert_approved"
|
||||
NotificationPrivatePhoto = "private_photo"
|
||||
NotificationCustom = "custom" // custom message pushed
|
||||
)
|
||||
|
||||
|
@ -62,6 +63,16 @@ func RemoveNotification(tableName string, tableID uint64) error {
|
|||
return result.Error
|
||||
}
|
||||
|
||||
// RemoveSpecificNotification to remove more specialized notifications where just removing by
|
||||
// table name+ID is not adequate, e.g. for Private Photo Unlocks.
|
||||
func RemoveSpecificNotification(userID uint64, t NotificationType, tableName string, tableID uint64) error {
|
||||
result := DB.Where(
|
||||
"user_id = ? AND type = ? AND table_name = ? AND table_id = ?",
|
||||
userID, t, tableName, tableID,
|
||||
).Delete(&Notification{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// MarkNotificationsRead sets all a user's notifications to read.
|
||||
func MarkNotificationsRead(user *User) error {
|
||||
return DB.Model(&Notification{}).Where(
|
||||
|
|
|
@ -149,16 +149,26 @@ func CountExplicitPhotos(userID uint64, visibility []PhotoVisibility) (int64, er
|
|||
return count, result.Error
|
||||
}
|
||||
|
||||
// PaginateGalleryPhotos gets a page of all public user photos for the site gallery. Admin view
|
||||
// returns ALL photos regardless of Gallery status.
|
||||
func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager *Pagination) ([]*Photo, error) {
|
||||
/*
|
||||
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, filterExplicit, filterVisibility string, pager *Pagination) ([]*Photo, error) {
|
||||
var (
|
||||
p = []*Photo{}
|
||||
query *gorm.DB
|
||||
blocklist = BlockedUserIDs(userID)
|
||||
friendIDs = FriendIDs(userID)
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
p = []*Photo{}
|
||||
query *gorm.DB
|
||||
|
||||
// Get the user ID and their preferences.
|
||||
userID = user.ID
|
||||
adminView = user.IsAdmin // Admins see everything on the site.
|
||||
explicitOK = user.Explicit // User opted-in for explicit content
|
||||
|
||||
blocklist = BlockedUserIDs(userID)
|
||||
friendIDs = FriendIDs(userID)
|
||||
privateUserIDs = PrivateGrantedUserIDs(userID)
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
)
|
||||
|
||||
// Include ourself in our friend IDs.
|
||||
|
@ -166,10 +176,14 @@ func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager
|
|||
|
||||
// You can see friends' Friend photos but only public for non-friends.
|
||||
wheres = append(wheres,
|
||||
"(user_id IN ? AND visibility IN ?) OR (user_id NOT IN ? AND visibility = ?)",
|
||||
"((user_id IN ? AND visibility IN ?) OR "+
|
||||
"(user_id IN ? AND visibility IN ?) OR "+
|
||||
"(user_id NOT IN ? AND visibility = ?))",
|
||||
)
|
||||
placeholders = append(placeholders,
|
||||
friendIDs, PhotoVisibilityFriends, friendIDs, PhotoPublic,
|
||||
friendIDs, PhotoVisibilityFriends,
|
||||
privateUserIDs, PhotoVisibilityAll,
|
||||
friendIDs, PhotoPublic,
|
||||
)
|
||||
|
||||
// Gallery photos only.
|
||||
|
@ -182,12 +196,21 @@ func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager
|
|||
placeholders = append(placeholders, blocklist)
|
||||
}
|
||||
|
||||
// Non-explicit pics unless the user opted in.
|
||||
if !explicitOK {
|
||||
// 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 user photos.
|
||||
wheres = append(wheres,
|
||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true)",
|
||||
|
@ -201,6 +224,14 @@ func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager
|
|||
// 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 "),
|
||||
|
|
147
pkg/models/private_photo.go
Normal file
147
pkg/models/private_photo.go
Normal file
|
@ -0,0 +1,147 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PrivatePhoto table to track who you have unlocked your private photos for.
|
||||
type PrivatePhoto struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
SourceUserID uint64 `gorm:"index"` // the owner of a photo
|
||||
TargetUserID uint64 `gorm:"index"` // the receiver
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UnlockPrivatePhotos is sourceUserId allowing targetUserId to see their private photos.
|
||||
func UnlockPrivatePhotos(sourceUserID, targetUserID uint64) error {
|
||||
// Did we already allow this user?
|
||||
var pb *PrivatePhoto
|
||||
exist := DB.Where(
|
||||
"source_user_id = ? AND target_user_id = ?",
|
||||
sourceUserID, targetUserID,
|
||||
).First(&pb).Error
|
||||
|
||||
// Update existing.
|
||||
if exist == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create the PrivatePhoto.
|
||||
pb = &PrivatePhoto{
|
||||
SourceUserID: sourceUserID,
|
||||
TargetUserID: targetUserID,
|
||||
}
|
||||
return DB.Create(pb).Error
|
||||
}
|
||||
|
||||
// RevokePrivatePhotos is sourceUserId revoking targetUserId to see their private photos.
|
||||
func RevokePrivatePhotos(sourceUserID, targetUserID uint64) error {
|
||||
result := DB.Where(
|
||||
"source_user_id = ? AND target_user_id = ?",
|
||||
sourceUserID, targetUserID,
|
||||
).Delete(&PrivatePhoto{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// RevokePrivatePhotosAll is sourceUserId revoking ALL USERS from their private photos.
|
||||
func RevokePrivatePhotosAll(sourceUserID uint64) error {
|
||||
result := DB.Where(
|
||||
"source_user_id = ?",
|
||||
sourceUserID,
|
||||
).Delete(&PrivatePhoto{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see.
|
||||
func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool {
|
||||
pb := &PrivatePhoto{}
|
||||
result := DB.Where(
|
||||
"source_user_id = ? AND target_user_id = ?",
|
||||
sourceUserID, targetUserID,
|
||||
).First(&pb)
|
||||
return result.Error == nil
|
||||
}
|
||||
|
||||
// CountPrivateGrantee returns how many users have granted you access to their private photos.
|
||||
func CountPrivateGrantee(userID uint64) int64 {
|
||||
var count int64
|
||||
DB.Model(&PrivatePhoto{}).Where(
|
||||
"target_user_id = ?",
|
||||
userID,
|
||||
).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// PrivateGrantedUserIDs returns all user IDs who have granted access for userId to see their private photos.
|
||||
func PrivateGrantedUserIDs(userId uint64) []uint64 {
|
||||
var (
|
||||
ps = []*PrivatePhoto{}
|
||||
userIDs = []uint64{userId}
|
||||
)
|
||||
DB.Where("target_user_id = ?", userId).Find(&ps)
|
||||
for _, row := range ps {
|
||||
userIDs = append(userIDs, row.SourceUserID)
|
||||
}
|
||||
return userIDs
|
||||
}
|
||||
|
||||
/*
|
||||
PaginatePrivatePhotoList views a user's list of private photo grants.
|
||||
|
||||
If grantee is true, it returns the list of users who have granted YOU access to see THEIR
|
||||
private photos. If grantee is false, it returns the users that YOU have granted access to
|
||||
see YOUR OWN private photos.
|
||||
*/
|
||||
func PaginatePrivatePhotoList(userID uint64, grantee bool, pager *Pagination) ([]*User, error) {
|
||||
var (
|
||||
pbs = []*PrivatePhoto{}
|
||||
userIDs = []uint64{}
|
||||
query *gorm.DB
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
)
|
||||
|
||||
// Which direction are we going?
|
||||
if grantee {
|
||||
// Return the private photo grants for whom YOU are the recipient.
|
||||
wheres = append(wheres, "target_user_id = ?")
|
||||
placeholders = append(placeholders, userID)
|
||||
} else {
|
||||
// Return the users that YOU have granted access to YOUR private pictures.
|
||||
wheres = append(wheres, "source_user_id = ?")
|
||||
placeholders = append(placeholders, userID)
|
||||
}
|
||||
|
||||
query = DB.Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
)
|
||||
|
||||
query = query.Order(pager.Sort)
|
||||
query.Model(&PrivatePhoto{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&pbs)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
// Now of these user IDs get their User objects.
|
||||
for _, b := range pbs {
|
||||
if grantee {
|
||||
userIDs = append(userIDs, b.SourceUserID)
|
||||
} else {
|
||||
userIDs = append(userIDs, b.TargetUserID)
|
||||
}
|
||||
}
|
||||
|
||||
return GetUsers(userIDs)
|
||||
}
|
||||
|
||||
// Save photo.
|
||||
func (pb *PrivatePhoto) Save() error {
|
||||
result := DB.Save(pb)
|
||||
return result.Error
|
||||
}
|
|
@ -46,6 +46,8 @@ func New() http.Handler {
|
|||
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
|
||||
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
|
||||
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
|
||||
mux.Handle("/photo/private", middleware.LoginRequired(photo.Private()))
|
||||
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
|
||||
mux.Handle("/messages", middleware.LoginRequired(inbox.Inbox()))
|
||||
mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox()))
|
||||
mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose()))
|
||||
|
|
|
@ -39,6 +39,12 @@
|
|||
.has-text-private-light {
|
||||
color: #FF99FF;
|
||||
}
|
||||
.hero.is-private {
|
||||
background-color: #b748c7;
|
||||
}
|
||||
.hero.is-private.is-bold {
|
||||
background-image: linear-gradient(141deg,#b329b1 0,#9948c7 71%,#7156d2 100%);
|
||||
}
|
||||
|
||||
/* Mobile: notification badge near the hamburger menu */
|
||||
.nonshy-mobile-notification {
|
||||
|
@ -57,3 +63,8 @@
|
|||
.nonshy-fullwidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Collapsible cards for mobile (e.g. filter cards) */
|
||||
.card.nonshy-collapsible-mobile {
|
||||
cursor: pointer;
|
||||
}
|
|
@ -89,4 +89,41 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
target.classList.remove("is-active");
|
||||
});
|
||||
});
|
||||
|
||||
// Collapsible cards for mobile view (e.g. People Search filters box)
|
||||
(document.querySelectorAll(".card.nonshy-collapsible-mobile") || []).forEach(node => {
|
||||
const header = node.querySelector(".card-header"),
|
||||
body = node.querySelector(".card-content"),
|
||||
icon = header.querySelector("button.card-header-icon > .icon > i");
|
||||
|
||||
// Icon classes.
|
||||
const iconExpanded = "fa-angle-up",
|
||||
iconContracted = "fa-angle-down";
|
||||
|
||||
// If we are already on mobile, hide the body now.
|
||||
if (screen.width <= 768) {
|
||||
body.style.display = "none";
|
||||
if (icon !== null) {
|
||||
icon.classList.remove(iconExpanded);
|
||||
icon.classList.add(iconContracted);
|
||||
}
|
||||
}
|
||||
|
||||
// Add click toggle handler to the header.
|
||||
header.addEventListener("click", () => {
|
||||
if (body.style.display === "none") {
|
||||
body.style.display = "block";
|
||||
if (icon !== null) {
|
||||
icon.classList.remove(iconContracted);
|
||||
icon.classList.add(iconExpanded);
|
||||
}
|
||||
} else {
|
||||
body.style.display = "none";
|
||||
if (icon !== null) {
|
||||
icon.classList.remove(iconExpanded);
|
||||
icon.classList.add(iconContracted);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
|
@ -93,6 +93,13 @@
|
|||
Upload Photos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/photo/private">
|
||||
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||
Manage Private Photos
|
||||
<span class="tag is-success ml-1">NEW!</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/settings">
|
||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||
|
@ -250,6 +257,13 @@
|
|||
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
|
||||
accepted your friend request!
|
||||
</span>
|
||||
{{else if eq .Type "private_photo"}}
|
||||
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
|
||||
<span>
|
||||
<a href="/u/{{.AboutUser.Username}}"><strong>{{.AboutUser.Username}}</strong></a>
|
||||
has granted you access to see their
|
||||
<a href="{{.Link}}" class="has-text-private">private photos</a>!
|
||||
</span>
|
||||
{{else if eq .Type "cert_approved"}}
|
||||
<span class="icon"><i class="fa fa-certificate has-text-success"></i></span>
|
||||
<span>
|
||||
|
|
|
@ -35,11 +35,16 @@
|
|||
|
||||
<div class="block">
|
||||
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<div class="card nonshy-collapsible-mobile">
|
||||
<header class="card-header has-background-link-light">
|
||||
<p class="card-header-title">
|
||||
Search Filters
|
||||
</p>
|
||||
<button class="card-header-icon" type="button">
|
||||
<span class="icon">
|
||||
<i class="fa fa-angle-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="columns is-multiline">
|
||||
|
|
|
@ -157,6 +157,10 @@
|
|||
<span class="icon"><i class="fa fa-upload"></i></span>
|
||||
<span>Upload Photo</span>
|
||||
</a>
|
||||
<a class="navbar-item" href="/photo/private">
|
||||
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||
<span>Private Photos</span>
|
||||
</a>
|
||||
<a class="navbar-item" href="/settings">
|
||||
<span class="icon"><i class="fa fa-gear"></i></span>
|
||||
<span>Settings</span>
|
||||
|
|
|
@ -166,7 +166,7 @@
|
|||
</div>
|
||||
|
||||
<div class="block">
|
||||
<div class="level">
|
||||
<div class="level{{if .IsOwnPhotos}}mb-0{{end}}">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<span>
|
||||
|
@ -194,6 +194,102 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<form action="/photo/gallery" method="GET">
|
||||
|
||||
<div class="card nonshy-collapsible-mobile">
|
||||
<header class="card-header has-background-link-light">
|
||||
<p class="card-header-title">
|
||||
Search Filters <span class="tag is-success ml-2">NEW</span>
|
||||
</p>
|
||||
<button class="card-header-icon" type="button">
|
||||
<span class="icon">
|
||||
<i class="fa fa-angle-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="columns is-multiline mb-0">
|
||||
|
||||
{{if .CurrentUser.Explicit}}
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label" for="explicit">Explicit:</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select id="explicit" name="explicit">
|
||||
<option value="">Show all</option>
|
||||
<option value="true"{{if eq .FilterExplicit "true"}} selected{{end}}>Only explicit</option>
|
||||
<option value="false"{{if eq .FilterExplicit "false"}} selected{{end}}>Hide explicit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label" for="visibility">Visibility:</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select id="visibility" name="visibility">
|
||||
<option value="">All photos</option>
|
||||
<option value="public"{{if eq .FilterVisibility "public"}} selected{{end}}>Public only</option>
|
||||
<option value="friends"{{if eq .FilterVisibility "friends"}} selected{{end}}>Friends only</option>
|
||||
<option value="private"{{if eq .FilterVisibility "private"}} selected{{end}}>Private only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label" for="sort">Sort by:</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select id="sort" name="sort">
|
||||
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Most recent</option>
|
||||
<option value="created_at asc"{{if eq .Sort "created_at asc"}} selected{{end}}>Oldest first</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="has-text-centered">
|
||||
<a href="/photo/gallery" class="button">Reset</a>
|
||||
<button type="submit" class="button is-success">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if .IsOwnPhotos}}
|
||||
<div class="block">
|
||||
<a href="/photo/private" class="has-text-private">
|
||||
<span class="icon"><i class="fa fa-lock"></i></span>
|
||||
<span>Manage who can see <strong>my</strong> private photos</span>
|
||||
<span class="tag is-success">NEW</span>
|
||||
</a>
|
||||
</div>
|
||||
{{else if and (not .IsSiteGallery) (not .IsMyPrivateUnlockedFor)}}
|
||||
<div class="block">
|
||||
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
|
||||
<span class="icon"><i class="fa fa-unlock"></i></span>
|
||||
<span>Grant <strong>{{.User.Username}}</strong> access to see <strong>my</strong> private photos</span>
|
||||
<span class="tag is-success">NEW</span>
|
||||
</a>
|
||||
</div>
|
||||
{{else if and (not .IsSiteGallery) .IsMyPrivateUnlockedFor}}
|
||||
<div class="block">
|
||||
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
|
||||
<span>You had granted <strong>{{.User.Username}}</strong> access to see <strong>your</strong> private photos.</span>
|
||||
<a href="/photo/private">Manage that here.</a>
|
||||
<span class="tag is-success">NEW</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{template "pager" .}}
|
||||
|
||||
<!-- "Full" view style? (blog style) -->
|
||||
|
|
157
web/templates/photo/private.html
Normal file
157
web/templates/photo/private.html
Normal file
|
@ -0,0 +1,157 @@
|
|||
{{define "title"}}Private Photos{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container">
|
||||
{{$Root := .}}
|
||||
<section class="hero is-private is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title has-text-light">
|
||||
<i class="fa fa-eye mr-2"></i>
|
||||
Private Photos
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="p-4 is-text-centered">
|
||||
<div class="level">
|
||||
<div class="level-item">
|
||||
<div class="tabs is-toggle">
|
||||
<ul>
|
||||
<li{{if not .IsGrantee}} class="is-active"{{end}}>
|
||||
<a href="/photo/private">My Shares</a>
|
||||
</li>
|
||||
<li{{if .IsGrantee}} class="is-active"{{end}}>
|
||||
<a href="/photo/private?view=grantee">
|
||||
Shared With Me
|
||||
{{if .CountGrantee}}
|
||||
<span class="tag is-grey ml-2">{{.CountGrantee}}</span>
|
||||
{{end}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
|
||||
<div class="block">
|
||||
{{if .IsGrantee}}
|
||||
{{.Pager.Total}} member{{Pluralize64 .Pager.Total}}
|
||||
{{Pluralize64 .Pager.Total "has" "have"}}
|
||||
granted you access to see their private photos
|
||||
{{else}}
|
||||
You have granted access to {{.Pager.Total}} member{{Pluralize64 .Pager.Total}}
|
||||
to see your private photos
|
||||
{{end}}
|
||||
|
||||
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||
</div>
|
||||
|
||||
{{if not .IsGrantee}}
|
||||
<div class="columns is-gapless is-centered">
|
||||
<div class="column is-narrow mx-1 my-2">
|
||||
<a href="/photo/private/share" class="button is-primary is-outlined is-fullwidth">
|
||||
<span class="icon"><i class="fa fa-plus"></i></span>
|
||||
<span>Add new share</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="column is-narrow mx-1 my-2">
|
||||
<a href="/photo/private/share?intent=revoke-all" class="button is-danger is-outlined is-fullwidth"
|
||||
onclick="return confirm('Are you sure you want to lock your Private Photos from ALL users?')">
|
||||
<span class="icon"><i class="fa fa-lock"></i></span>
|
||||
<span>Revoke ALL Shares</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="block">
|
||||
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||
<a class="pagination-previous{{if not .Pager.HasPrevious}} is-disabled{{end}}" title="Previous"
|
||||
href="{{.Request.URL.Path}}?{{if .IsGrantee}}view=grantee&{{end}}page={{.Pager.Previous}}">Previous</a>
|
||||
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
|
||||
href="{{.Request.URL.Path}}?{{if .IsGrantee}}view=grantee&{{end}}page={{.Pager.Next}}">Next page</a>
|
||||
<ul class="pagination-list">
|
||||
{{$Root := .}}
|
||||
{{range .Pager.Iter}}
|
||||
<li>
|
||||
<a class="pagination-link{{if .IsCurrent}} is-current{{end}}"
|
||||
aria-label="Page {{.Page}}"
|
||||
href="{{$Root.Request.URL.Path}}?{{if $Root.IsGrantee}}view=grantee&{{end}}page={{.Page}}">
|
||||
{{.Page}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
|
||||
{{range .Users}}
|
||||
<div class="column is-half-tablet is-one-third-desktop">
|
||||
|
||||
<form action="/photo/private/share" method="POST">
|
||||
{{InputCSRF}}
|
||||
<input type="hidden" name="to" value="{{.Username}}">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="media block">
|
||||
<div class="media-left">
|
||||
<figure class="image is-64x64">
|
||||
<a href="/u/{{.Username}}">
|
||||
{{if .ProfilePhoto.ID}}
|
||||
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
|
||||
{{else}}
|
||||
<img src="/static/img/shy.png">
|
||||
{{end}}
|
||||
</a>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p class="title is-4">
|
||||
<a href="/u/{{.Username}}" class="has-text-dark">{{.NameOrUsername}}</a>
|
||||
</p>
|
||||
<p class="subtitle is-6">
|
||||
<span class="icon"><i class="fa fa-user"></i></span>
|
||||
<a href="/u/{{.Username}}">{{.Username}}</a>
|
||||
{{if not .Certified}}
|
||||
<span class="has-text-danger">
|
||||
<span class="icon"><i class="fa fa-certificate"></i></span>
|
||||
<span>Not Certified!</span>
|
||||
</span>
|
||||
{{end}}
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<span class="has-text-danger">
|
||||
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||
<span>Admin</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if not $Root.IsGrantee}}
|
||||
<footer class="card-footer">
|
||||
<button type="submit" name="intent" value="revoke" class="card-footer-item button is-danger is-outlined"
|
||||
onclick="return confirm('Are you sure you want to revoke private photo access to this user?')">
|
||||
<span class="icon"><i class="fa fa-xmark"></i></span>
|
||||
<span>Revoke Access</span>
|
||||
</button>
|
||||
</footer>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{{end}}<!-- range .Friends -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
117
web/templates/photo/share.html
Normal file
117
web/templates/photo/share.html
Normal file
|
@ -0,0 +1,117 @@
|
|||
{{define "title"}}Share Private Photos{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero is-info is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
Share Private Photos
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="block p-4">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
|
||||
<div class="card" style="width: 100%; max-width: 640px">
|
||||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">
|
||||
<span class="icon mr-2"><i class="fa fa-eye"></i></span>
|
||||
<span>Share Private Photos</span>
|
||||
</p>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
|
||||
<!-- Show the introduction or selected user profile -->
|
||||
{{if not .User}}
|
||||
<div class="block">
|
||||
You may use this page to grant access to your
|
||||
<span class="has-text-private">
|
||||
<i class="fa fa-eye"></i>
|
||||
Private Photos
|
||||
</span>
|
||||
to another member on this site by entering their
|
||||
username below.
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="block">
|
||||
Confirm that you wish to grant <strong>{{.User.Username}}</strong>
|
||||
access to view your
|
||||
<span class="has-text-private">
|
||||
<i class="fa fa-eye"></i>
|
||||
Private Photos
|
||||
</span>
|
||||
by clicking the button below.
|
||||
</div>
|
||||
<div class="media block">
|
||||
<div class="media-left">
|
||||
<figure class="image is-64x64">
|
||||
{{if .User.ProfilePhoto.ID}}
|
||||
<img src="{{PhotoURL .User.ProfilePhoto.CroppedFilename}}">
|
||||
{{else}}
|
||||
<img src="/static/img/shy.png">
|
||||
{{end}}
|
||||
</figure>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p class="title is-4">{{.NameOrUsername}}</p>
|
||||
<p class="subtitle is-6">
|
||||
<span class="icon"><i class="fa fa-user"></i></span>
|
||||
<a href="/u/{{.User.Username}}" target="_blank">{{.User.Username}}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Show the relevant form -->
|
||||
<form action="/photo/private/share" method="POST">
|
||||
{{InputCSRF}}
|
||||
|
||||
{{if .User}}
|
||||
<input type="hidden" name="to" value="{{.User.Username}}">
|
||||
{{else}}
|
||||
<div class="field block">
|
||||
<label for="to" class="label">Share with Username:</label>
|
||||
<input type="text" class="input"
|
||||
name="to" id="to"
|
||||
placeholder="username">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="field has-text-centered">
|
||||
<button type="submit" class="button is-success"
|
||||
name="intent"
|
||||
value="{{if .User}}submit{{else}}preview{{end}}">
|
||||
{{if .User}}
|
||||
<span class="icon"><i class="fa fa-unlock"></i></span>
|
||||
<span>Confirm Share</span>
|
||||
{{else}}
|
||||
Continue
|
||||
{{end}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
window.addEventListener("DOMContentLoaded", (event) => {
|
||||
let $file = document.querySelector("#file"),
|
||||
$fileName = document.querySelector("#fileName");
|
||||
|
||||
$file.addEventListener("change", function() {
|
||||
let file = this.files[0];
|
||||
$fileName.innerHTML = file.name;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
|
@ -214,7 +214,7 @@
|
|||
name="visibility"
|
||||
value="public"
|
||||
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
|
||||
<strong class="has-text-link">
|
||||
<strong class="has-text-link ml-1">
|
||||
<span>Public</span>
|
||||
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||
</strong>
|
||||
|
@ -231,7 +231,7 @@
|
|||
name="visibility"
|
||||
value="friends"
|
||||
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
|
||||
<strong class="has-text-warning-dark">
|
||||
<strong class="has-text-warning-dark ml-1">
|
||||
<span>Friends only</span>
|
||||
<span class="icon"><i class="fa fa-user-group"></i></span>
|
||||
</strong>
|
||||
|
@ -248,7 +248,7 @@
|
|||
name="visibility"
|
||||
value="private"
|
||||
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
|
||||
<strong class="has-text-private">
|
||||
<strong class="has-text-private ml-1">
|
||||
<span>Private</span>
|
||||
<span class="icon"><i class="fa fa-lock"></i></span>
|
||||
</strong>
|
||||
|
@ -258,6 +258,15 @@
|
|||
granted access (the latter feature is coming soon!)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="notification is-warning is-light p-2 is-size-7">
|
||||
<i class="fa fa-warning"></i> <strong>Notice:</strong> the square cropped
|
||||
thumbnail of your Default Profile Picture will always be visible on your
|
||||
profile and displayed alongside your username elsewhere on the site. The above Visibility
|
||||
setting <em>can</em> limit the full-size photo's visibility; but the square cropped
|
||||
thumbnail is always seen to logged-in members.
|
||||
</div>
|
||||
|
||||
<div class="field mb-5">
|
||||
|
|
Loading…
Reference in New Issue
Block a user