From 96e5b1abfac22ef6c0abde9254325b9717b957d0 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 14 Aug 2022 17:45:55 -0700 Subject: [PATCH] Block Lists Implement block lists. They work like friend lists but are unidirectional, but take effect in both directions (blocker and blockee can not see one another on the site -- except admin users can always see all users). * Profile page says 404 * User gallery says 404 * User search page filters out blocked users * Compose endpoint blocks sending messages to blocked users (except admin) * Site Gallery filters photos by blocked (and uncertified) users * Inbox page hides chat list for blocked users (can still read the chat history if you have a link to the old thread) --- pkg/config/page_sizes.go | 1 + pkg/controller/account/profile.go | 6 ++ pkg/controller/account/search.go | 10 +- pkg/controller/block/block.go | 114 ++++++++++++++++++++ pkg/controller/inbox/compose.go | 7 ++ pkg/controller/photo/site_gallery.go | 2 +- pkg/controller/photo/user_gallery.go | 6 ++ pkg/models/blocklist.go | 124 ++++++++++++++++++++++ pkg/models/message.go | 27 ++++- pkg/models/models.go | 1 + pkg/models/photo.go | 38 +++++-- pkg/models/user.go | 18 ++-- pkg/router/router.go | 3 + web/templates/account/block_list.html | 99 +++++++++++++++++ web/templates/account/dashboard.html | 6 ++ web/templates/account/profile.html | 140 +++++++++++++------------ web/templates/account/settings.html | 2 +- web/templates/admin/certification.html | 2 +- 18 files changed, 514 insertions(+), 92 deletions(-) create mode 100644 pkg/controller/block/block.go create mode 100644 pkg/models/blocklist.go create mode 100644 web/templates/account/block_list.html diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index 9d71b01..bd70937 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -4,6 +4,7 @@ package config var ( PageSizeMemberSearch = 60 PageSizeFriends = 12 + PageSizeBlockList = 12 PageSizeAdminCertification = 20 PageSizeSiteGallery = 18 PageSizeUserGallery = 18 diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index a94513e..f8cd245 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -43,6 +43,12 @@ func Profile() http.HandlerFunc { return } + // Is either one blocking? + if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin { + templates.NotFoundPage(w, r) + return + } + vars := map[string]interface{}{ "User": user, "IsFriend": models.FriendStatus(currentUser.ID, user.ID), diff --git a/pkg/controller/account/search.go b/pkg/controller/account/search.go index f30304d..8e88b50 100644 --- a/pkg/controller/account/search.go +++ b/pkg/controller/account/search.go @@ -42,6 +42,14 @@ func Search() http.HandlerFunc { ageMin, ageMax = ageMax, ageMin } + // Get current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user!") + templates.Redirect(w, "/") + return + } + // Sort options. for _, v := range sortWhitelist { if sort == v { @@ -64,7 +72,7 @@ func Search() http.HandlerFunc { } pager.ParsePage(r) - users, err := models.SearchUsers(&models.UserSearch{ + users, err := models.SearchUsers(currentUser.ID, &models.UserSearch{ EmailOrUsername: username, Gender: gender, Orientation: orientation, diff --git a/pkg/controller/block/block.go b/pkg/controller/block/block.go new file mode 100644 index 0000000..92a16f4 --- /dev/null +++ b/pkg/controller/block/block.go @@ -0,0 +1,114 @@ +package block + +import ( + "net/http" + "strings" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Blocked list. +func Blocked() http.HandlerFunc { + tmpl := templates.Must("account/block_list.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: could not get currentUser.") + templates.Redirect(w, "/") + return + } + + // Get our blocklist. + pager := &models.Pagination{ + PerPage: config.PageSizeBlockList, + Sort: "updated_at desc", + } + pager.ParsePage(r) + blocked, err := models.PaginateBlockList(currentUser.ID, pager) + if err != nil { + session.FlashError(w, r, "Couldn't paginate block list: %s", err) + templates.Redirect(w, "/") + return + } + + var vars = map[string]interface{}{ + "BlockedUsers": blocked, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} + +// BlockUser controller. +func BlockUser() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // POST only. + if r.Method != http.MethodPost { + session.FlashError(w, r, "Unacceptable Request Method") + templates.Redirect(w, "/") + return + } + + // Form fields + var ( + username = strings.ToLower(r.PostFormValue("username")) + unblock = r.PostFormValue("unblock") == "true" + ) + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get CurrentUser: %s", err) + templates.Redirect(w, "/") + return + } + + // Get the target user. + user, err := models.FindUser(username) + if err != nil { + session.FlashError(w, r, "User Not Found") + templates.Redirect(w, "/") + return + } + + // Unblocking? + if unblock { + if err := models.UnblockUser(currentUser.ID, user.ID); err != nil { + session.FlashError(w, r, "Couldn't unblock this user: %s.", err) + } else { + session.Flash(w, r, "You have removed %s from your block list.", user.Username) + } + templates.Redirect(w, "/users/blocked") + return + } + + // Can't block yourself. + if currentUser.ID == user.ID { + session.FlashError(w, r, "You can't block yourself!") + templates.Redirect(w, "/u/"+username) + return + } + + // Can't block admins. + if user.IsAdmin { + session.FlashError(w, r, "You can not block site administrators.") + templates.Redirect(w, "/u/"+username) + return + } + + // Block the target user. + if err := models.AddBlock(currentUser.ID, user.ID); err != nil { + session.FlashError(w, r, "Couldn't block this user: %s.", err) + } else { + session.Flash(w, r, "You have added %s to your block list.", user.Username) + } + + templates.Redirect(w, "/users/blocked") + }) +} diff --git a/pkg/controller/inbox/compose.go b/pkg/controller/inbox/compose.go index b9ad097..0870e7b 100644 --- a/pkg/controller/inbox/compose.go +++ b/pkg/controller/inbox/compose.go @@ -34,6 +34,13 @@ func Compose() http.HandlerFunc { return } + // Any blocking? + if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin { + session.FlashError(w, r, "You are blocked from sending a message to this user.") + templates.Redirect(w, "/messages") + return + } + // POSTing? if r.Method == http.MethodPost { var ( diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index 1e2d930..8593627 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -34,7 +34,7 @@ func SiteGallery() http.HandlerFunc { Sort: "created_at desc", } pager.ParsePage(r) - photos, err := models.PaginateGalleryPhotos(currentUser.IsAdmin, currentUser.Explicit, pager) + photos, err := models.PaginateGalleryPhotos(currentUser.ID, currentUser.IsAdmin, currentUser.Explicit, pager) // Bulk load the users associated with these photos. var userIDs = []uint64{} diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 795f737..f75b467 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -46,6 +46,12 @@ func UserPhotos() http.HandlerFunc { } var isOwnPhotos = currentUser.ID == user.ID + // Is either one blocking? + if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin { + templates.NotFoundPage(w, r) + return + } + // What set of visibilities to query? visibility := []models.PhotoVisibility{models.PhotoPublic} if isOwnPhotos || currentUser.IsAdmin { diff --git a/pkg/models/blocklist.go b/pkg/models/blocklist.go new file mode 100644 index 0000000..80801be --- /dev/null +++ b/pkg/models/blocklist.go @@ -0,0 +1,124 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Block table. +type Block struct { + ID uint64 `gorm:"primaryKey"` + SourceUserID uint64 `gorm:"index"` + TargetUserID uint64 `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// AddBlock is sourceUserId adding targetUserId to their block list. +func AddBlock(sourceUserID, targetUserID uint64) error { + // Unfriend in the process. + RemoveFriend(sourceUserID, targetUserID) + + // Did we already block this user? + var b *Block + forward := DB.Where( + "source_user_id = ? AND target_user_id = ?", + sourceUserID, targetUserID, + ).First(&b).Error + + // Update existing. + if forward == nil { + return nil + } + + // Create the block. + b = &Block{ + SourceUserID: sourceUserID, + TargetUserID: targetUserID, + } + return DB.Create(b).Error +} + +// IsBlocking quickly sees if either user blocks the other. +func IsBlocking(sourceUserID, targetUserID uint64) bool { + b := &Block{} + result := DB.Where( + "(source_user_id = ? AND target_user_id = ?) OR "+ + "(target_user_id = ? AND source_user_id = ?)", + sourceUserID, targetUserID, + sourceUserID, targetUserID, + ).First(&b) + return result.Error == nil +} + +// IsBlocked quickly checks if sourceUserID currently blocks targetUserID. +func IsBlocked(sourceUserID, targetUserID uint64) bool { + b := &Block{} + result := DB.Where( + "source_user_id = ? AND target_user_id = ?", + sourceUserID, targetUserID, + ).First(&b) + return result.Error == nil +} + +// PaginateBlockList views a user's blocklist. +func PaginateBlockList(userID uint64, pager *Pagination) ([]*User, error) { + // We paginate over the Block table. + var ( + bs = []*Block{} + userIDs = []uint64{} + query *gorm.DB + ) + + query = DB.Where( + "source_user_id = ?", + userID, + ) + + query = query.Order(pager.Sort) + query.Model(&Block{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&bs) + if result.Error != nil { + return nil, result.Error + } + + // Now of these friends get their User objects. + for _, b := range bs { + userIDs = append(userIDs, b.TargetUserID) + } + + return GetUsers(userIDs) +} + +// BlockedUserIDs returns all user IDs blocked by the user. +func BlockedUserIDs(userId uint64) []uint64 { + var ( + bs = []*Block{} + userIDs = []uint64{} + ) + DB.Where("source_user_id = ? OR target_user_id = ?", userId, userId).Find(&bs) + for _, row := range bs { + for _, uid := range []uint64{row.TargetUserID, row.SourceUserID} { + if uid != userId { + userIDs = append(userIDs, uid) + } + } + } + return userIDs +} + +// UnblockUser removes targetUserID from your blocklist. +func UnblockUser(sourceUserID, targetUserID uint64) error { + result := DB.Where( + "source_user_id = ? AND target_user_id = ?", + sourceUserID, targetUserID, + ).Delete(&Block{}) + return result.Error +} + +// Save photo. +func (b *Block) Save() error { + result := DB.Save(b) + return result.Error +} diff --git a/pkg/models/message.go b/pkg/models/message.go index 1e01f29..1a6dfee 100644 --- a/pkg/models/message.go +++ b/pkg/models/message.go @@ -1,6 +1,7 @@ package models import ( + "strings" "time" ) @@ -25,15 +26,33 @@ func GetMessage(id uint64) (*Message, error) { // GetMessages for a user. func GetMessages(userID uint64, sent bool, pager *Pagination) ([]*Message, error) { var ( - m = []*Message{} - where = "target_user_id = ?" + m = []*Message{} + blockedUserIDs = BlockedUserIDs(userID) + where = []string{} + placeholders = []interface{}{} ) + if sent { - where = "source_user_id" + where = append(where, "source_user_id = ?") + placeholders = append(placeholders, userID) + + if len(blockedUserIDs) > 0 { + where = append(where, "target_user_id NOT IN ?") + placeholders = append(placeholders, blockedUserIDs) + } + } else { + where = append(where, "target_user_id = ?") + placeholders = append(placeholders, userID) + + if len(blockedUserIDs) > 0 { + where = append(where, "source_user_id NOT IN ?") + placeholders = append(placeholders, blockedUserIDs) + } } query := DB.Where( - where, userID, + strings.Join(where, " AND "), + placeholders..., ).Order(pager.Sort) query.Model(&Message{}).Count(&pager.Total) diff --git a/pkg/models/models.go b/pkg/models/models.go index 877c952..c44c956 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -14,4 +14,5 @@ func AutoMigrate() { DB.AutoMigrate(&CertificationPhoto{}) DB.AutoMigrate(&Message{}) DB.AutoMigrate(&Friend{}) + DB.AutoMigrate(&Block{}) } diff --git a/pkg/models/photo.go b/pkg/models/photo.go index d9ce4f6..bc2e910 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -2,6 +2,7 @@ package models import ( "errors" + "strings" "time" "gorm.io/gorm" @@ -111,26 +112,43 @@ func CountExplicitPhotos(userID uint64, visibility []PhotoVisibility) (int64, er // PaginateGalleryPhotos gets a page of all public user photos for the site gallery. Admin view // returns ALL photos regardless of Gallery status. -func PaginateGalleryPhotos(adminView bool, explicitOK bool, pager *Pagination) ([]*Photo, error) { +func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager *Pagination) ([]*Photo, error) { var ( - p = []*Photo{} - query *gorm.DB + p = []*Photo{} + query *gorm.DB + blocklist = BlockedUserIDs(userID) + wheres = []string{} + placeholders = []interface{}{} ) - var explicit = []bool{false} - if explicitOK { - explicit = []bool{true, false} + // Universal filters: public + gallery photos only. + wheres = append(wheres, "visibility = ?", "gallery = ?") + placeholders = append(placeholders, PhotoPublic, 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. + if !explicitOK { + wheres = append(wheres, "explicit = ?") + placeholders = append(placeholders, false) + } + + // Only certified user photos. + wheres = append(wheres, + "EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true)", + ) + // Admin view: get ALL PHOTOS on the site, period. if adminView { query = DB } else { query = DB.Where( - "visibility = ? AND gallery = ? AND explicit IN ?", - PhotoPublic, - true, - explicit, + strings.Join(wheres, " AND "), + placeholders..., ) } diff --git a/pkg/models/user.go b/pkg/models/user.go index a9ba2c9..f9c8e45 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -124,19 +124,25 @@ type UserSearch struct { AgeMax int } -// SearchUsers -func SearchUsers(search *UserSearch, pager *Pagination) ([]*User, error) { +// 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{}{} + 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 ?)") diff --git a/pkg/router/router.go b/pkg/router/router.go index 9985614..c4f8d48 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -8,6 +8,7 @@ import ( "git.kirsle.net/apps/gosocial/pkg/controller/account" "git.kirsle.net/apps/gosocial/pkg/controller/admin" "git.kirsle.net/apps/gosocial/pkg/controller/api" + "git.kirsle.net/apps/gosocial/pkg/controller/block" "git.kirsle.net/apps/gosocial/pkg/controller/friend" "git.kirsle.net/apps/gosocial/pkg/controller/inbox" "git.kirsle.net/apps/gosocial/pkg/controller/index" @@ -41,6 +42,8 @@ func New() http.Handler { mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose())) mux.Handle("/friends", middleware.LoginRequired(friend.Friends())) mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend())) + mux.Handle("/users/block", middleware.LoginRequired(block.BlockUser())) + mux.Handle("/users/blocked", middleware.LoginRequired(block.Blocked())) mux.Handle("/admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate())) // Certification Required. Pages that only full (verified) members can access. diff --git a/web/templates/account/block_list.html b/web/templates/account/block_list.html new file mode 100644 index 0000000..e73dc51 --- /dev/null +++ b/web/templates/account/block_list.html @@ -0,0 +1,99 @@ +{{define "title"}}Blocked Users{{end}} +{{define "content"}} +
+ {{$Root := .}} + + +
+ +
+ You have blocked {{.Pager.Total}} user{{Pluralize64 .Pager.Total}} + (page {{.Pager.Page}} of {{.Pager.Pages}}). +
+ +
+ +
+ +
+ + {{range .BlockedUsers}} +
+ +
+ {{InputCSRF}} + + +
+
+
+
+
+ {{if .ProfilePhoto.ID}} + + {{else}} + + {{end}} +
+
+
+

{{or .Name "(no name)"}}

+

+ + {{.Username}} + {{if not .Certified}} + + + Not Certified! + + {{end}} + + {{if .IsAdmin}} + + + Admin + + {{end}} +

+
+
+
+
+ +
+
+
+ +
+ {{end}} +
+ +
+
+{{end}} \ No newline at end of file diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 229c745..4a5b4fc 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -105,6 +105,12 @@ Certification Photo +
  • + + + Blocked Users + +
  • diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 7c15bdc..c4127a8 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -89,77 +89,81 @@ - diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html index 467423a..73e7eff 100644 --- a/web/templates/account/settings.html +++ b/web/templates/account/settings.html @@ -51,7 +51,7 @@ id="display_name" name="display_name" placeholder="John Doe" - value="{{$User.Name}}"> + value="{{or $User.Name ""}}">
    diff --git a/web/templates/admin/certification.html b/web/templates/admin/certification.html index 687dc60..34efb7e 100644 --- a/web/templates/admin/certification.html +++ b/web/templates/admin/certification.html @@ -119,7 +119,7 @@
    - Status: + Status:
    {{if eq .Status "pending"}}