Deactivate Account; Friends Lists on Profiles

* Add a way for users to temporarily deactivate their accounts, in a
  recoverable way should they decide to return later.
* A deactivated account may log in but have limited options: to
  reactivate their account, permanently delete it, or log out.
* Fix several bugs around the display of comments, messages and
  forum threads for disabled, banned, or blocked users:
  * Messages (inbox and sentbox) will be hidden and the unread indicator
    will not count unread messages the user can't access.
  * Comments on photos and forum posts are hidden, and top-level threads
    on the "Newest" tab will show "[unavailable]" for their text and
    username.
  * Your historical notifications will hide users who are blocked, banned
    or disabled.
* Add a "Friends" tab to user profile pages, to see other users' friends.
  * The page is Certification Required so non-cert users can't easily
    discover any members on the site.
This commit is contained in:
Noah Petherbridge 2023-10-22 15:02:24 -07:00
parent 0f35f135d2
commit 481bd0ae61
31 changed files with 951 additions and 81 deletions

View File

@ -0,0 +1,82 @@
package account
import (
"net/http"
"strings"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Deactivate account page (self service).
func Deactivate() http.HandlerFunc {
tmpl := templates.Must("account/deactivate.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get your current user: %s", err)
templates.Redirect(w, "/")
return
}
// Confirm deletion.
if r.Method == http.MethodPost {
var password = strings.TrimSpace(r.PostFormValue("password"))
if err := currentUser.CheckPassword(password); err != nil {
session.FlashError(w, r, "You must enter your correct account password to delete your account.")
templates.Redirect(w, r.URL.Path)
return
}
// Deactivate their account!
currentUser.Status = models.UserStatusDisabled
if err := currentUser.Save(); err != nil {
session.FlashError(w, r, "Error while deactivating your account: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Sign them out.
session.LogoutUser(w, r)
session.Flash(w, r, "Your account has been deactivated and you are now logged out. If you wish to re-activate your account, sign in again with your username and password.")
templates.Redirect(w, "/")
return
}
var vars = map[string]interface{}{}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// Reactivate account page
func Reactivate() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get your current user: %s", err)
templates.Redirect(w, "/")
return
}
if currentUser.Status != models.UserStatusDisabled {
session.FlashError(w, r, "Your account was not disabled in the first place!")
templates.Redirect(w, "/")
return
}
// Reactivate them.
currentUser.Status = models.UserStatusActive
if err := currentUser.Save(); err != nil {
session.FlashError(w, r, "Error while reactivating your account: %s", err)
templates.Redirect(w, "/")
return
}
session.Flash(w, r, "Welcome back! Your account has been reactivated.")
templates.Redirect(w, "/")
})
}

View File

@ -0,0 +1,88 @@
package account
import (
"net/http"
"regexp"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
var UserFriendsRegexp = regexp.MustCompile(`^/friends/u/([^@]+?)$`)
// User friends page (/friends/u/username)
func UserFriends() http.HandlerFunc {
tmpl := templates.Must("account/friends.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters.
var username string
m := UserFriendsRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user.
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Get the viewer.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
templates.Redirect(w, "/")
return
}
var isSelf = currentUser.ID == user.ID
// Banned or disabled? Only admin can view then.
if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Is either one blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Get their friends.
pager := &models.Pagination{
PerPage: config.PageSizeFriends,
Sort: "updated_at desc",
}
pager.ParsePage(r)
friends, err := models.PaginateOtherUserFriends(currentUser, user, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate friends: %s", err)
templates.Redirect(w, "/")
return
}
// Inject relationship booleans.
models.SetUserRelationships(currentUser, friends)
var vars = map[string]interface{}{
"User": user,
"IsSelf": isSelf,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"Friends": friends,
"Pager": pager,
// Map our friendships to these users.
"FriendMap": models.MapFriends(currentUser, friends),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -64,8 +64,8 @@ func Login() http.HandlerFunc {
return
}
// Is their account banned or disabled?
if user.Status != models.UserStatusActive {
// Is their account banned?
if user.Status == models.UserStatusBanned {
session.FlashError(w, r, "Your account has been %s. If you believe this was done in error, please contact support.", user.Status)
templates.Redirect(w, r.URL.Path)
return

View File

@ -120,6 +120,7 @@ func Profile() http.HandlerFunc {
"IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
// Details on who likes the photo.

View File

@ -142,6 +142,7 @@ func UserNotes() http.HandlerFunc {
"User": user,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"MyNote": myNote,
// Admin concerns.

View File

@ -191,7 +191,7 @@ func FilteredChatStatistics(currentUser *models.User) worker.ChatStatistics {
// Who are we blocking?
var blockedUsernames = map[string]interface{}{}
for _, username := range models.BlockedUsernames(currentUser.ID) {
for _, username := range models.BlockedUsernames(currentUser) {
blockedUsernames[username] = nil
}
@ -211,7 +211,7 @@ func FilteredChatStatistics(currentUser *models.User) worker.ChatStatistics {
// SendBlocklist syncs the user blocklist to the chat server prior to sending them over.
func SendBlocklist(user *models.User) error {
// Get the user's blocklist.
blockedUsernames := models.BlockedUsernames(user.ID)
blockedUsernames := models.BlockedUsernames(user)
log.Info("SendBlocklist(%s) to BareRTC: %d blocked usernames", user.Username, len(blockedUsernames))
// API request struct for BareRTC /api/blocklist endpoint.

View File

@ -95,13 +95,13 @@ func Inbox() http.HandlerFunc {
// On the main inbox view, ?page= params page thru the message list, not a thread.
pager.ParsePage(r)
}
messages, err := models.GetMessages(currentUser.ID, showSent, pager)
messages, err := models.GetMessages(currentUser, showSent, pager)
if err != nil {
session.FlashError(w, r, "Couldn't get your messages from DB: %s", err)
}
// How many unreads?
unread, err := models.CountUnreadMessages(currentUser.ID)
unread, err := models.CountUnreadMessages(currentUser)
if err != nil {
session.FlashError(w, r, "Couldn't get your unread message count from DB: %s", err)
}

View File

@ -149,6 +149,7 @@ func UserPhotos() http.HandlerFunc {
"Photos": photos,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"PublicPhotoCount": models.CountPublicPhotos(user.ID),
"InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos,
"Pager": pager,

View File

@ -27,19 +27,19 @@ func LoginRequired(handler http.Handler) http.Handler {
return
}
// Are they banned or disabled?
if user.Status == models.UserStatusDisabled {
session.LogoutUser(w, r)
session.FlashError(w, r, "Your account has been disabled and you are now logged out.")
templates.Redirect(w, "/")
return
} else if user.Status == models.UserStatusBanned {
// Are they banned?
if user.Status == models.UserStatusBanned {
session.LogoutUser(w, r)
session.FlashError(w, r, "Your account has been banned and you are now logged out.")
templates.Redirect(w, "/")
return
}
// Is their account disabled?
if DisabledAccount(user, w, r) {
return
}
// Is the site under a Maintenance Mode restriction?
if MaintenanceMode(user, w, r) {
return
@ -115,19 +115,19 @@ func CertRequired(handler http.Handler) http.Handler {
return
}
// Are they banned or disabled?
if currentUser.Status == models.UserStatusDisabled {
session.LogoutUser(w, r)
session.FlashError(w, r, "Your account has been disabled and you are now logged out.")
templates.Redirect(w, "/")
return
} else if currentUser.Status == models.UserStatusBanned {
// Are they banned?
if currentUser.Status == models.UserStatusBanned {
session.LogoutUser(w, r)
session.FlashError(w, r, "Your account has been banned and you are now logged out.")
templates.Redirect(w, "/")
return
}
// Is their account disabled?
if DisabledAccount(currentUser, w, r) {
return
}
// Is the site under a Maintenance Mode restriction?
if MaintenanceMode(currentUser, w, r) {
return

View File

@ -0,0 +1,39 @@
package middleware
import (
"net/http"
"strings"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/templates"
)
var tmplDisabledAccount = templates.Must("errors/disabled_account.html")
// Whitelist of paths to allow disabled accounts to access.
var disabledAccountPathWhitelist = []string{
"/account/delete",
"/account/reactivate",
}
// DisabledAccount check that limits a logged-in user's options to either reactivate their account,
// delete it, or log back out.
func DisabledAccount(currentUser *models.User, w http.ResponseWriter, r *http.Request) bool {
// Is their account disabled?
if currentUser.Status == models.UserStatusDisabled {
// Whitelisted paths?
for _, path := range disabledAccountPathWhitelist {
if strings.HasPrefix(r.URL.Path, path) {
return false
}
}
// Show the disabled account page to all other requests.
if err := tmplDisabledAccount.Execute(w, r, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return true
}
return false
}

View File

@ -93,27 +93,36 @@ func PaginateBlockList(user *User, pager *Pagination) ([]*User, error) {
}
// BlockedUserIDs returns all user IDs blocked by the user (bidirectional, source or target user).
func BlockedUserIDs(userId uint64) []uint64 {
func BlockedUserIDs(user *User) []uint64 {
// Have we looked this up already on this request?
if user.cacheBlockedUserIDs != nil {
return user.cacheBlockedUserIDs
}
var (
bs = []*Block{}
userIDs = []uint64{}
)
DB.Where("source_user_id = ? OR target_user_id = ?", userId, userId).Find(&bs)
DB.Where("source_user_id = ? OR target_user_id = ?", user.ID, user.ID).Find(&bs)
for _, row := range bs {
for _, uid := range []uint64{row.TargetUserID, row.SourceUserID} {
if uid != userId {
if uid != user.ID {
userIDs = append(userIDs, uid)
}
}
}
// Cache the result in the User so we don't query it again.
user.cacheBlockedUserIDs = userIDs
return userIDs
}
// MapBlockedUserIDs returns BlockedUserIDs as a lookup hashmap (not for front-end templates currently).
func MapBlockedUserIDs(userId uint64) map[uint64]interface{} {
func MapBlockedUserIDs(user *User) map[uint64]interface{} {
var (
result = map[uint64]interface{}{}
blockedIDs = BlockedUserIDs(userId)
blockedIDs = BlockedUserIDs(user)
)
for _, uid := range blockedIDs {
result[uid] = nil
@ -125,7 +134,7 @@ func MapBlockedUserIDs(userId uint64) map[uint64]interface{} {
func FilterBlockingUserIDs(currentUser *User, userIDs []uint64) []uint64 {
var (
// Get the IDs to exclude.
blockedIDs = MapBlockedUserIDs(currentUser.ID)
blockedIDs = MapBlockedUserIDs(currentUser)
// Filter the result.
result = []uint64{}
@ -157,9 +166,9 @@ func BlockedUserIDsByUser(userId uint64) []uint64 {
}
// BlockedUsernames returns all usernames blocked by (or blocking) the user.
func BlockedUsernames(userId uint64) []string {
func BlockedUsernames(user *User) []string {
var (
userIDs = BlockedUserIDs(userId)
userIDs = BlockedUserIDs(user)
usernames = []string{}
)
@ -174,7 +183,7 @@ func BlockedUsernames(userId uint64) []string {
).Where(
"id IN ?", userIDs,
).Scan(&usernames); res.Error != nil {
log.Error("BlockedUsernames(%d): %s", userId, res.Error)
log.Error("BlockedUsernames(%d): %s", user.Username, res.Error)
}
return usernames

View File

@ -71,7 +71,7 @@ func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagin
var (
cs = []*Comment{}
query = (&Comment{}).Preload()
blockedUserIDs = BlockedUserIDs(user.ID)
blockedUserIDs = BlockedUserIDs(user)
wheres = []string{}
placeholders = []interface{}{}
)
@ -84,6 +84,16 @@ func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagin
placeholders = append(placeholders, blockedUserIDs)
}
// Don't show comments from banned or disabled accounts.
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM users
WHERE users.id = comments.user_id
AND users.status = 'active'
)
`)
query = query.Where(
strings.Join(wheres, " AND "),
placeholders...,
@ -102,7 +112,7 @@ func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagin
func ListComments(user *User, tableName string, tableID uint64) ([]*Comment, error) {
var (
cs []*Comment
blockedUserIDs = BlockedUserIDs(user.ID)
blockedUserIDs = BlockedUserIDs(user)
wheres = []string{}
placeholders = []interface{}{}
)
@ -115,6 +125,16 @@ func ListComments(user *User, tableName string, tableID uint64) ([]*Comment, err
placeholders = append(placeholders, blockedUserIDs)
}
// Don't show comments from banned or disabled accounts.
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM users
WHERE users.id = comments.user_id
AND users.status = 'active'
)
`)
result := (&Comment{}).Preload().Where(
strings.Join(wheres, " AND "),
placeholders...,

View File

@ -24,6 +24,7 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
var (
result = []*RecentPost{}
query = (&Comment{}).Preload()
blockedUserIDs = BlockedUserIDs(user)
wheres = []string{"table_name = 'threads'"}
placeholders = []interface{}{}
)
@ -43,6 +44,22 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
wheres = append(wheres, "forums.inner_circle is not true")
}
// Blocked users?
if len(blockedUserIDs) > 0 {
wheres = append(wheres, "comments.user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
// Don't show comments from banned or disabled accounts.
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM users
WHERE users.id = comments.user_id
AND users.status = 'active'
)
`)
// Get the page of recent forum comment IDs of all time.
type scanner struct {
CommentID uint64
@ -130,7 +147,7 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
comments, _ = GetComments(commentIDs)
}
if len(threadIDs) > 0 {
threads, _ = GetThreads(threadIDs)
threads, _ = GetThreadsAsUser(user, threadIDs)
}
if len(forumIDs) > 0 {
forums, _ = GetForums(forumIDs)
@ -154,6 +171,14 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
thrs = append(thrs, thr)
} else {
log.Error("RecentPosts: didn't find thread ID %d in map!", rc.ThreadID)
// Create a dummy placeholder Thread (e.g.: the thread originator has been
// banned or disabled, but the thread summary is shown on the new comment view)
rc.Thread = &Thread{
Comment: Comment{
Message: "[unavailable]",
},
}
}
if f, ok := forums[rc.ForumID]; ok {

View File

@ -2,6 +2,7 @@ package models
import (
"errors"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/log"
@ -246,6 +247,20 @@ func CountFriendRequests(userID uint64) (int64, error) {
return count, result.Error
}
// CountFriends gets a count of friends for the user.
func CountFriends(userID uint64) int64 {
var count int64
result := DB.Where(
"target_user_id = ? AND approved = ? AND EXISTS (SELECT 1 FROM users WHERE users.id = friends.source_user_id AND users.status = 'active')",
userID,
true,
).Model(&Friend{}).Count(&count)
if result.Error != nil {
log.Error("CountFriends(%d): %s", userID, result.Error)
}
return count
}
/*
PaginateFriends gets a page of friends (or pending friend requests) as User objects ordered
by friendship date.
@ -302,6 +317,62 @@ func PaginateFriends(user *User, requests bool, sent bool, pager *Pagination) ([
return GetUsers(user, userIDs)
}
/*
PaginateOtherUserFriends gets a page of friends from another user, for their profile page.
*/
func PaginateOtherUserFriends(currentUser *User, user *User, pager *Pagination) ([]*User, error) {
// We paginate over the Friend table.
var (
fs = []*Friend{}
blockedUserIDs = BlockedUserIDs(currentUser)
userIDs = []uint64{}
wheres = []string{}
placeholders = []interface{}{}
query = DB.Model(&Friend{})
)
// Get friends of the target user.
wheres = append(wheres, "source_user_id = ? AND approved = ?")
placeholders = append(placeholders, user.ID, true)
// Don't show our blocked users in the result.
if len(blockedUserIDs) > 0 {
wheres = append(wheres, "target_user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
// Don't show disabled or banned users.
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM users
WHERE users.id = friends.target_user_id
AND users.status = 'active'
)
`)
query = query.Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)
// Get the total count.
query.Count(&pager.Total)
// Get the page.
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
if result.Error != nil {
return nil, result.Error
}
// Now of these friends get their User objects.
for _, friend := range fs {
userIDs = append(userIDs, friend.TargetUserID)
}
return GetUsers(user, userIDs)
}
// GetFriendRequests returns all pending friend requests for a user.
func GetFriendRequests(userID uint64) ([]*Friend, error) {
var fs = []*Friend{}

View File

@ -73,7 +73,7 @@ func WhoLikes(currentUser *User, tableName string, tableID uint64) ([]*User, int
remainder = total
wheres = []string{}
placeholders = []interface{}{}
blockedUserIDs = BlockedUserIDs(currentUser.ID)
blockedUserIDs = BlockedUserIDs(currentUser)
)
wheres = append(wheres, "table_name = ? AND table_id = ?")
@ -117,7 +117,7 @@ func PaginateLikes(currentUser *User, tableName string, tableID uint64, pager *P
userIDs = []uint64{}
wheres = []string{}
placeholders = []interface{}{}
blockedUserIDs = BlockedUserIDs(currentUser.ID)
blockedUserIDs = BlockedUserIDs(currentUser)
)
wheres = append(wheres, "table_name = ? AND table_id = ?")

View File

@ -24,17 +24,17 @@ func GetMessage(id uint64) (*Message, error) {
}
// GetMessages for a user.
func GetMessages(userID uint64, sent bool, pager *Pagination) ([]*Message, error) {
func GetMessages(user *User, sent bool, pager *Pagination) ([]*Message, error) {
var (
m = []*Message{}
blockedUserIDs = BlockedUserIDs(userID)
blockedUserIDs = BlockedUserIDs(user)
where = []string{}
placeholders = []interface{}{}
)
if sent {
where = append(where, "source_user_id = ?")
placeholders = append(placeholders, userID)
placeholders = append(placeholders, user.ID)
if len(blockedUserIDs) > 0 {
where = append(where, "target_user_id NOT IN ?")
@ -42,7 +42,7 @@ func GetMessages(userID uint64, sent bool, pager *Pagination) ([]*Message, error
}
} else {
where = append(where, "target_user_id = ?")
placeholders = append(placeholders, userID)
placeholders = append(placeholders, user.ID)
if len(blockedUserIDs) > 0 {
where = append(where, "source_user_id NOT IN ?")
@ -50,6 +50,16 @@ func GetMessages(userID uint64, sent bool, pager *Pagination) ([]*Message, error
}
}
// Don't show messages from banned or disabled accounts.
where = append(where, `
NOT EXISTS (
SELECT 1
FROM users
WHERE users.id IN (messages.target_user_id, messages.source_user_id)
AND users.status <> 'active'
)
`)
query := DB.Where(
strings.Join(where, " AND "),
placeholders...,
@ -100,11 +110,36 @@ func DeleteMessageThread(message *Message) error {
}
// CountUnreadMessages gets the count of unread messages for a user.
func CountUnreadMessages(userID uint64) (int64, error) {
query := DB.Where(
func CountUnreadMessages(user *User) (int64, error) {
var (
blockedUserIDs = BlockedUserIDs(user)
where = []string{
"target_user_id = ? AND read = ?",
userID,
false,
}
placeholders = []interface{}{
user.ID, false,
}
)
// Blocking user IDs?
if len(blockedUserIDs) > 0 {
where = append(where, "source_user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
// Don't show messages from banned or disabled accounts.
where = append(where, `
NOT EXISTS (
SELECT 1
FROM users
WHERE users.id IN (messages.target_user_id, messages.source_user_id)
AND users.status <> 'active'
)
`)
query := DB.Where(
strings.Join(where, " AND "),
placeholders...,
)
var count int64

View File

@ -1,6 +1,7 @@
package models
import (
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/log"
@ -139,11 +140,36 @@ func ClearAllNotifications(user *User) error {
}
// CountUnreadNotifications gets the count of unread Notifications for a user.
func CountUnreadNotifications(userID uint64) (int64, error) {
query := DB.Where(
func CountUnreadNotifications(user *User) (int64, error) {
var (
blockedUserIDs = BlockedUserIDs(user)
where = []string{
"user_id = ? AND read = ?",
userID,
false,
}
placeholders = []interface{}{
user.ID, false,
}
)
// Blocking user IDs?
if len(blockedUserIDs) > 0 {
where = append(where, "about_user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
// Don't show messages from banned or disabled accounts.
where = append(where, `
EXISTS (
SELECT 1
FROM users
WHERE users.id = notifications.about_user_id
AND users.status = 'active'
)
`)
query := DB.Where(
strings.Join(where, " AND "),
placeholders...,
)
var count int64
@ -153,11 +179,36 @@ func CountUnreadNotifications(userID uint64) (int64, error) {
// PaginateNotifications returns the user's notifications.
func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, error) {
var ns = []*Notification{}
var (
ns = []*Notification{}
blockedUserIDs = BlockedUserIDs(user)
where = []string{
"user_id = ?",
}
placeholders = []interface{}{
user.ID,
}
)
// Suppress historic notifications about blocked users.
if len(blockedUserIDs) > 0 {
where = append(where, "about_user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
// Don't show notifications from banned or disabled accounts.
where = append(where, `
EXISTS (
SELECT 1
FROM users
WHERE users.id = notifications.about_user_id
AND users.status = 'active'
)
`)
query := (&Notification{}).Preload().Where(
"user_id = ?",
user.ID,
strings.Join(where, " AND "),
placeholders...,
).Order(
pager.Sort,
)
@ -348,8 +399,6 @@ func (nm NotificationMap) mapNotificationComments(IDs []uint64) {
commentIDs = append(commentIDs, row.CommentID)
}
log.Error("MAP COMMENT IDS GOT: %+v", commentIDs)
// Load the comments.
if len(commentIDs) > 0 {
if comments, err := GetComments(commentIDs); err != nil {

View File

@ -397,7 +397,7 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
userID = user.ID
explicitOK = user.Explicit // User opted-in for explicit content
blocklist = BlockedUserIDs(userID)
blocklist = BlockedUserIDs(user)
friendIDs = FriendIDs(userID)
privateUserIDs = PrivateGrantedUserIDs(userID)
wheres = []string{}

View File

@ -47,9 +47,65 @@ func GetThreads(IDs []uint64) (map[uint64]*Thread, error) {
var (
mt = map[uint64]*Thread{}
ts = []*Thread{}
wheres = []string{"threads.id IN ?"}
placeholders = []interface{}{IDs}
)
result := (&Thread{}).Preload().Where("id IN ?", IDs).Find(&ts)
// Don't show threads from banned or disabled accounts.
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM users
WHERE users.id = comments.user_id
AND users.status = 'active'
)
`)
result := (&Thread{}).Preload().Joins(
"LEFT OUTER JOIN comments ON (comments.id = threads.comment_id)",
).Where(
strings.Join(wheres, " AND "),
placeholders...,
).Find(&ts)
for _, row := range ts {
mt[row.ID] = row
}
return mt, result.Error
}
// GetThreadsAsUser queries a set of thread IDs and returns them mapped, taking blocklists into consideration.
func GetThreadsAsUser(currentUser *User, IDs []uint64) (map[uint64]*Thread, error) {
var (
mt = map[uint64]*Thread{}
ts = []*Thread{}
blockedUserIDs = BlockedUserIDs(currentUser)
wheres = []string{"threads.id IN ?"}
placeholders = []interface{}{IDs}
)
// Blocked users?
if len(blockedUserIDs) > 0 {
wheres = append(wheres, "comments.user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
// Don't show threads from banned or disabled accounts.
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM users
WHERE users.id = comments.user_id
AND users.status = 'active'
)
`)
result := (&Thread{}).Preload().Joins(
"LEFT OUTER JOIN comments ON (comments.id = threads.comment_id)",
).Where(
strings.Join(wheres, " AND "),
placeholders...,
).Find(&ts)
for _, row := range ts {
mt[row.ID] = row
}
@ -170,7 +226,19 @@ func PaginateThreads(user *User, forum *Forum, pager *Pagination) ([]*Thread, er
wheres = append(wheres, "explicit IS NOT TRUE")
}
query = query.Where(
// Don't show threads from banned or disabled accounts.
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM users
WHERE users.id = comments.user_id
AND users.status = 'active'
)
`)
query = query.Joins(
"LEFT OUTER JOIN comments ON (comments.id = threads.comment_id)",
).Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)

View File

@ -44,6 +44,7 @@ type User struct {
// Caches
cachePhotoTypes map[PhotoVisibility]struct{}
cacheBlockedUserIDs []uint64
}
type UserVisibility string
@ -231,7 +232,7 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
joins string // GPS location join.
wheres = []string{}
placeholders = []interface{}{}
blockedUserIDs = BlockedUserIDs(user.ID)
blockedUserIDs = BlockedUserIDs(user)
myLocation = GetUserLocation(user.ID)
)

View File

@ -47,6 +47,8 @@ func New() http.Handler {
mux.Handle("/settings/age-gate", middleware.LoginRequired(account.AgeGate()))
mux.Handle("/account/two-factor/setup", middleware.LoginRequired(account.Setup2FA()))
mux.Handle("/account/delete", middleware.LoginRequired(account.Delete()))
mux.Handle("/account/deactivate", middleware.LoginRequired(account.Deactivate()))
mux.Handle("/account/reactivate", middleware.LoginRequired(account.Reactivate()))
mux.Handle("/u/", account.Profile()) // public access OK
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos()))
@ -63,6 +65,7 @@ func New() http.Handler {
mux.Handle("/messages/delete", middleware.LoginRequired(inbox.Delete()))
mux.Handle("/friends", middleware.LoginRequired(friend.Friends()))
mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend()))
mux.Handle("/friends/u/", middleware.CertRequired(account.UserFriends()))
mux.Handle("/users/block", middleware.LoginRequired(block.BlockUser()))
mux.Handle("/users/blocked", middleware.LoginRequired(block.Blocked()))
mux.Handle("/users/blocklist/add", middleware.LoginRequired(block.AddUser()))

View File

@ -77,7 +77,7 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
)
// Get unread message count.
if count, err := models.CountUnreadMessages(user.ID); err == nil {
if count, err := models.CountUnreadMessages(user); err == nil {
m["NavUnreadMessages"] = count
countMessages = count
} else {
@ -93,7 +93,7 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
}
// Count other notifications.
if count, err := models.CountUnreadNotifications(user.ID); err == nil {
if count, err := models.CountUnreadNotifications(user); err == nil {
m["NavUnreadNotifications"] = count
countNotifications = count
} else {

View File

@ -236,7 +236,7 @@
</li>
{{end}}
<li>
<a href="/account/delete">
<a href="/settings#deactivate">
<span class="icon"><i class="fa fa-trash"></i></span>
Delete account
</a>

View File

@ -0,0 +1,86 @@
{{define "title"}}Deactivate Account{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Deactivate Account
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="columns is-centered">
<div class="column is-half">
<div class="card" style="max-width: 512px">
<header class="card-header has-background-danger">
<p class="card-header-title has-text-light">
<span class="icon"><i class="fa fa-trash"></i></span>
Deactivate My Account
</p>
</header>
<div class="card-content">
<form method="POST" action="/account/deactivate">
{{InputCSRF}}
<div class="block content">
<p>
On this page you may <strong>temporarily deactivate your account</strong>, which
will hide your profile from everywhere on the website (as if your account were
deleted), but in a recoverable way where you may log in and re-activate your
account in the future, should you decide to come back.
</p>
<p>
When you deactivate your account:
</p>
<ul>
<li>
Your profile will be hidden from everywhere on the website: for example
you will not be searchable on the Member Directory.
</li>
<li>
People you had exchanged Messages with will no longer see your conversations
in their inbox or outbox page.
</li>
<li>
All comments and forum posts you made will be hidden from everybody on
the website.
</li>
<li>
You will be signed out of your {{PrettyTitle}} account. If you wish to
re-activate your account in the future, you may sign back in and the only
options you will be given will be to re-activate your account, delete it
permanently, or log out.
</li>
</ul>
<p>
To confirm deactivation of your account, please enter your current account
password into the box below.
</p>
</div>
<div class="field">
<label class="label" for="password">Your current password:</label>
<input type="password" class="input"
name="password"
id="password"
placeholder="Password">
</div>
<div class="block has-text-center">
<button type="submit" class="button is-danger">Deactivate My Account</button>
<a href="/me" class="button is-success">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,159 @@
{{define "title"}}Friends of {{.User.Username}}{{end}}
{{define "content"}}
<div class="container">
{{$Root := .}}
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
<i class="fa fa-user-group mr-2"></i>
Friends of {{.User.Username}}
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="tabs is-boxed mb-0">
<ul>
<li>
<a href="/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-user"></i>
</span>
<span>Profile</span>
</a>
</li>
<li>
<a href="/photo/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-image"></i>
</span>
<span>
Photos
{{if .PhotoCount}}<span class="tag is-link is-light ml-1">{{.PhotoCount}}</span>{{end}}
</span>
</a>
</li>
<li>
<a href="/notes/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-pen-to-square"></i>
</span>
<span>
Notes
{{if .NoteCount}}<span class="tag is-link is-light ml-1">{{.NoteCount}}</span>{{end}}
</span>
</a>
</li>
<li class="is-active">
<a href="/friends/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-user-group"></i>
</span>
<span>
Friends
{{if .FriendCount}}<span class="tag is-link is-light ml-1">{{.FriendCount}}</span>{{end}}
</span>
</a>
</li>
</ul>
</div>
</div>
<div class="p-4">
<div class="block">
Found {{.Pager.Total}} friend{{Pluralize64 .Pager.Total}}
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</div>
<div class="block">
{{SimplePager .Pager}}
</div>
<div class="columns is-multiline">
{{range .Friends}}
<div class="column is-half-tablet is-one-third-desktop">
<form action="/friends/add" 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">
{{template "avatar-64x64" .}}
<!-- Friendship badge -->
{{if $Root.FriendMap.Get .ID}}
<div class="has-text-centered">
<span class="is-size-7 has-text-warning-dark">
<i class="fa fa-user-group" title="Friends"></i>
Friends
</span>
</div>
{{end}}
</div>
<div class="media-content">
<p class="title is-4">
<a href="/u/{{.Username}}" class="has-text-dark">
{{if ne .Status "active"}}
<del>{{.NameOrUsername}}</del>
{{else}}
{{.NameOrUsername}}
{{end}}
{{if and $Root.CurrentUser.IsInnerCircle .InnerCircle}}
<img src="/static/img/circle-16.png">
{{end}}
</a>
{{if eq .Visibility "private"}}
<sup class="fa fa-mask is-size-7" title="Private Profile"></sup>
{{end}}
</p>
<p class="subtitle is-6 mb-2">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.Username}}">{{.Username}}</a>
</p>
{{if .GetProfileField "city"}}
<p class="subtitle is-6 mb-2">
{{.GetProfileField "city"}}
</p>
{{end}}
<p class="subtitle is-7 mb-2">
{{if or (ne .GetDisplayAge "n/a")}}
<span class="mr-2">{{.GetDisplayAge}}</span>
{{end}}
{{if .GetProfileField "gender"}}
<span class="mr-2">{{.GetProfileField "gender"}}</span>
{{end}}
{{if .GetProfileField "pronouns"}}
<span class="mr-2">{{.GetProfileField "pronouns"}}</span>
{{end}}
{{if .GetProfileField "orientation"}}
<span class="mr-2">{{.GetProfileField "orientation"}}</span>
{{end}}
</p>
</div>
</div><!-- media-block -->
</div>
</div>
</form>
</div>
{{end}}<!-- range .Friends -->
</div>
<div class="block">
{{SimplePager .Pager}}
</div>
</div>
</div>
{{end}}

View File

@ -295,6 +295,17 @@
</span>
</a>
</li>
<li>
<a href="/friends/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-user-group"></i>
</span>
<span>
Friends
{{if .FriendCount}}<span class="tag is-link is-light ml-1">{{.FriendCount}}</span>{{end}}
</span>
</a>
</li>
</ul>
</div>

View File

@ -65,7 +65,16 @@
<a href="/settings#account" class="nonshy-tab-button">
<strong><i class="fa fa-user mr-1"></i> Account Settings</strong>
<p class="help">
Change password or e-mail; Two-factor auth (2FA); delete account.
Change password or e-mail; set up Two-Factor Authentication (2FA).
</p>
</a>
</li>
<li>
<a href="/settings#deactivate" class="nonshy-tab-button">
<strong><i class="fa fa-exclamation-triangle mr-1"></i> Deactivate Account</strong>
<p class="help">
Temporarily deactivate or permanently delete my account.
</p>
</a>
</li>
@ -744,13 +753,41 @@
</form>
</div>
</div>
</div>
<!-- Deactivate or Delete Account -->
<div id="deactivate">
<div class="card mb-5">
<header class="card-header has-background-warning">
<p class="card-header-title has-text-dark-dark">
<i class="fa fa-lock pr-2"></i>
Deactivate My Account
</p>
</header>
<div class="card-content content">
<p>
If you'd like to take a break from {{PrettyTitle}} but think you may want to
come back later, you may <strong>temporarily deactivate your account</strong>
which will mark your profile as hidden from everywhere on the website (as if
it were deleted), but in a way that you can recover your account and reactivate
it again in the future.
</p>
<p>
<a href="/account/deactivate" class="button is-primary">
Temporarily Deactivate My Account
</a>
</p>
</div>
</div>
<!-- Delete Account -->
<div class="card mb-5">
<header class="card-header has-background-danger">
<p class="card-header-title has-text-light">
<i class="fa fa-exclamation-triangle pr-2"></i>
Delete Account
Permanently Delete My Account
</p>
</header>
@ -762,7 +799,7 @@
<p class="block">
<a href="/account/delete" class="button is-danger">
Delete My Account
Permanently Delete My Account
</a>
</p>
</div>
@ -782,7 +819,8 @@ window.addEventListener("DOMContentLoaded", (event) => {
$prefs = document.querySelector("#prefs"),
$location = document.querySelector("#location"),
$privacy = document.querySelector("#privacy"),
$account = document.querySelector("#account"),
$account = document.querySelector("#account")
$deactivate = document.querySelector("#deactivate"),
buttons = Array.from(document.getElementsByClassName("nonshy-tab-button"));
// Hide all by default.
@ -791,6 +829,7 @@ window.addEventListener("DOMContentLoaded", (event) => {
$location.style.display = 'none';
$privacy.style.display = 'none';
$account.style.display = 'none';
$deactivate.style.display = 'none';
// Current tab to select by default.
let $activeTab = $profile;
@ -813,6 +852,9 @@ window.addEventListener("DOMContentLoaded", (event) => {
case "account":
$activeTab = $account;
break;
case "deactivate":
$activeTab = $deactivate;
break;
default:
$activeTab = $profile;
}

View File

@ -55,6 +55,17 @@
</span>
</a>
</li>
<li>
<a href="/friends/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-user-group"></i>
</span>
<span>
Friends
{{if .FriendCount}}<span class="tag is-link is-light ml-1">{{.FriendCount}}</span>{{end}}
</span>
</a>
</li>
</ul>
</div>

View File

@ -0,0 +1,57 @@
{{define "title"}}Reactivate Your Account{{end}}
{{define "content"}}
<div class="container">
<section class="hero block is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">Welcome back!</h1>
<h2 class="subtitle">Reactivate your account?</h2>
</div>
</div>
</section>
<div class="block content p-4 mb-0">
<h1>Your account is currently deactivated</h1>
<p>
Welcome back to {{PrettyTitle}}! You had requested to deactivate your account before, and you must make
a decision as to how you want to proceed from here.
</p>
<h2>Reactivate Your Account?</h2>
<p>
If you wish to pick up where you left off, you may <strong>reactivate your account</strong> by clicking
on the button below. It will be as if you never left: your profile, photo gallery, friends, messages and
notifications will all be how you left them.
</p>
<p>
<a href="/account/reactivate" class="button is-success">Reactivate My Account</a>
</p>
<h2>Permanently Delete Your Account?</h2>
<p>
If you've decided you really <em>don't</em> want to have a {{PrettyTitle}} account anymore, you may
click the button below to <strong>permanently delete your account</strong> instead. We will delete your
profile, photos, and all the associated data we have about your account. This will be an irreversible
process!
</p>
<p>
<a href="/account/delete" class="button is-danger">Permanently Delete My Account</a>
</p>
<h2>Log Out</h2>
<p>
If you're not sure what to do, you may log out of your account and come back later.
</p>
<p>
<a href="/account/logout" class="button is-info">Log out of my account</a>
</p>
</div>
</div>
{{end}}

View File

@ -51,7 +51,7 @@
<a href="/u/{{$User.Username}}">
{{template "avatar-96x96" $User}}
<div>
<a href="/u/{{$User.Username}}" class="is-size-7">{{$User.Username}}</a>
<a href="/u/{{$User.Username}}" class="is-size-7">{{or $User.Username "[unavailable]"}}</a>
</div>
</a>
</div>

View File

@ -145,6 +145,17 @@
</span>
</a>
</li>
<li>
<a href="/friends/u/{{.User.Username}}">
<span class="icon is-small">
<i class="fa fa-user-group"></i>
</span>
<span>
Friends
{{if .FriendCount}}<span class="tag is-link is-light ml-1">{{.FriendCount}}</span>{{end}}
</span>
</a>
</li>
</ul>
</div>
{{end}}