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)
This commit is contained in:
Noah 2022-08-14 17:45:55 -07:00
parent dbca37977e
commit 96e5b1abfa
18 changed files with 514 additions and 92 deletions

View File

@ -4,6 +4,7 @@ package config
var (
PageSizeMemberSearch = 60
PageSizeFriends = 12
PageSizeBlockList = 12
PageSizeAdminCertification = 20
PageSizeSiteGallery = 18
PageSizeUserGallery = 18

View File

@ -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),

View File

@ -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,

View File

@ -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")
})
}

View File

@ -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 (

View File

@ -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{}

View File

@ -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 {

124
pkg/models/blocklist.go Normal file
View File

@ -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
}

View File

@ -1,6 +1,7 @@
package models
import (
"strings"
"time"
)
@ -26,14 +27,32 @@ func GetMessage(id uint64) (*Message, error) {
func GetMessages(userID uint64, sent bool, pager *Pagination) ([]*Message, error) {
var (
m = []*Message{}
where = "target_user_id = ?"
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)

View File

@ -14,4 +14,5 @@ func AutoMigrate() {
DB.AutoMigrate(&CertificationPhoto{})
DB.AutoMigrate(&Message{})
DB.AutoMigrate(&Friend{})
DB.AutoMigrate(&Block{})
}

View File

@ -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
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...,
)
}

View File

@ -124,8 +124,8 @@ 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{}
}
@ -135,8 +135,14 @@ func SearchUsers(search *UserSearch, pager *Pagination) ([]*User, error) {
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 ?)")

View File

@ -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.

View File

@ -0,0 +1,99 @@
{{define "title"}}Blocked Users{{end}}
{{define "content"}}
<div class="container">
{{$Root := .}}
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">Blocked Users</h1>
</div>
</div>
</section>
<div class="p-4">
<div class="block">
You have blocked {{.Pager.Total}} user{{Pluralize64 .Pager.Total}}
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</div>
<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}}?page={{.Pager.Previous}}">Previous</a>
<a class="pagination-next{{if not .Pager.HasNext}} is-disabled{{end}}" title="Next"
href="{{.Request.URL.Path}}?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}}?page={{.Page}}">
{{.Page}}
</a>
</li>
{{end}}
</ul>
</nav>
</div>
<div class="columns is-multiline">
{{range .BlockedUsers}}
<div class="column is-half-tablet is-one-third-desktop">
<form action="/users/block" method="POST">
{{InputCSRF}}
<input type="hidden" name="username" value="{{.Username}}">
<div class="card">
<div class="card-content">
<div class="media block">
<div class="media-left">
<figure class="image is-64x64">
{{if .ProfilePhoto.ID}}
<img src="{{PhotoURL .ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{or .Name "(no name)"}}</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>
<footer class="card-footer">
<button type="submit" name="unblock" value="true" class="card-footer-item button is-danger">
<span class="icon"><i class="fa fa-xmark"></i></span>
<span>Unblock User</span>
</button>
</footer>
</div>
</form>
</div>
{{end}}<!-- range .BlockedUsers -->
</div>
</div>
</div>
{{end}}

View File

@ -105,6 +105,12 @@
Certification Photo
</a>
</li>
<li>
<a href="/users/blocked">
<span class="icon"><i class="fa fa-hand"></i></span>
Blocked Users
</a>
</li>
<li>
<a href="/logout">
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>

View File

@ -89,14 +89,13 @@
</div>
</div>
<div class="level">
<div class="level-item">
<div class="field has-addons">
<div class="columns is-centered is-gapless">
<div class="column is-narrow has-text-centered">
<form action="/friends/add" method="POST">
{{InputCSRF}}
<input type="hidden" name="username" value="{{.User.Username}}">
<p class="control">
<button type="submit" class="button"
<button type="submit" class="button is-fullwidth"
{{if not (eq .IsFriend "none")}}title="Friendship {{.IsFriend}}"{{end}}>
<span class="icon-text">
<span class="icon">
@ -111,11 +110,11 @@
<span>Friend{{if eq .IsFriend "approved"}}s{{end}}</span>
</span>
</button>
</p>
</form>
</div>
<p class="control">
<a href="/messages/compose?to={{.User.Username}}" class="button">
<div class="column is-narrow has-text-centered">
<a href="/messages/compose?to={{.User.Username}}" class="button is-fullwidth">
<span class="icon-text">
<span class="icon">
<i class="fa fa-message"></i>
@ -123,10 +122,10 @@
<span>Message</span>
</span>
</a>
</p>
</div>
<p class="control">
<button type="button" class="button">
<div class="column is-narrow has-text-centered">
<button type="button" class="button is-fullwidth">
<span class="icon-text">
<span class="icon">
<i class="fa fa-thumbs-up"></i>
@ -134,10 +133,10 @@
<span>Like</span>
</span>
</button>
</p>
</div>
<!-- <p class="control">
<button type="button" class="button">
<!-- <div class="column is-narrow has-text-centered">
<button type="button" class="button is-fullwidth">
<span class="icon-text">
<span class="icon">
<i class="fa fa-flag"></i>
@ -145,10 +144,15 @@
<span>Flag</span>
</span>
</button>
</p>
</div>-->
<p class="control">
<button type="button" class="button">
<div class="column is-narrow has-text-centered">
<form action="/users/block" method="POST">
{{InputCSRF}}
<input type="hidden" name="username" value="{{.User.Username}}">
<button type="submit" class="button is-fullwidth"
{{if not (eq .IsFriend "none")}}title="Friendship {{.IsFriend}}"{{end}}>
<span class="icon-text">
<span class="icon">
<i class="fa fa-hand"></i>
@ -156,10 +160,10 @@
<span>Block</span>
</span>
</button>
</p> -->
</div>
</div>
</form>
</div>
</div><!-- columns -->
</div>
</div>
</section>

View File

@ -51,7 +51,7 @@
id="display_name"
name="display_name"
placeholder="John Doe"
value="{{$User.Name}}">
value="{{or $User.Name ""}}">
</div>
<div class="column field is-half">

View File

@ -119,7 +119,7 @@
<div class="block">
<div class="columns">
<div class="column is-narrow">
<strong>Status:
<strong>Status:</strong>
</div>
<div class="column">
{{if eq .Status "pending"}}