DeletedUserMemory to restore blocklists on returning accounts

This commit is contained in:
Noah Petherbridge 2024-12-22 18:01:02 -08:00
parent 6a2e5d0809
commit 332bcc9f66
6 changed files with 191 additions and 1 deletions

View File

@ -273,6 +273,11 @@ func Signup() http.HandlerFunc {
user.Birthdate = birthdate
user.Save()
// Restore their block lists if this user has deleted their account before.
if err := models.RestoreDeletedUserMemory(user); err != nil {
log.Error("RestoreDeletedUserMemory(%s): %s", user.Username, err)
}
// Log in the user and send them to their dashboard.
session.LoginUser(w, r, user)
templates.Redirect(w, "/me")

View File

@ -166,6 +166,54 @@ func BlockedUserIDsByUser(userId uint64) []uint64 {
return userIDs
}
// GetAllBlockedUserIDs returns the forward and reverse lists of blocked user IDs for the user.
func GetAllBlockedUserIDs(user *User) (forward, reverse []uint64) {
var (
bs = []*Block{}
)
DB.Where("source_user_id = ? OR target_user_id = ?", user.ID, user.ID).Find(&bs)
for _, row := range bs {
if row.SourceUserID == user.ID {
forward = append(forward, row.TargetUserID)
} else if row.TargetUserID == user.ID {
reverse = append(reverse, row.SourceUserID)
}
}
return forward, reverse
}
// BulkRestoreBlockedUserIDs inserts many blocked user IDs in one query.
//
// Returns the count of blocks added.
func BulkRestoreBlockedUserIDs(user *User, forward, reverse []uint64) (int, error) {
var bs = []*Block{}
// Forward list.
for _, uid := range forward {
bs = append(bs, &Block{
SourceUserID: user.ID,
TargetUserID: uid,
})
}
// Reverse list.
for _, uid := range reverse {
bs = append(bs, &Block{
SourceUserID: uid,
TargetUserID: user.ID,
})
}
// Anything to do?
if len(bs) == 0 {
return 0, nil
}
// Batch create.
res := DB.Create(bs)
return len(bs), res.Error
}
// BlockedUsernames returns all usernames blocked by (or blocking) the user.
func BlockedUsernames(user *User) []string {
var (

View File

@ -0,0 +1,130 @@
package models
import (
"encoding/json"
"fmt"
"time"
"code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm/clause"
)
// DeletedUserMemory table stores security-related information, such as block lists, when a user
// deletes their account - so that when they sign up again later under the same username or email
// address, this information can be restored and other users on the site can have their block lists
// respected.
type DeletedUserMemory struct {
Username string `gorm:"uniqueIndex:idx_deleted_user_memory"`
HashedEmail string `gorm:"uniqueIndex:idx_deleted_user_memory"`
Data string
CreatedAt time.Time
UpdatedAt time.Time
}
// DeletedUserMemoryData is a JSON serializable struct for data stored in the DeletedUserMemory table.
type DeletedUserMemoryData struct {
PreviousUsername string
BlockingUserIDs []uint64
BlockedByUserIDs []uint64
}
// CreateDeletedUserMemory creates the row in the database just before a user account is deleted.
func CreateDeletedUserMemory(user *User) error {
// Get the user's blocked lists.
forward, reverse := GetAllBlockedUserIDs(user)
// Store the memory.
data := DeletedUserMemoryData{
PreviousUsername: user.Username,
BlockingUserIDs: forward,
BlockedByUserIDs: reverse,
}
bin, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("JSON marshal: %s", err)
}
// Upsert the mute.
m := &DeletedUserMemory{
Username: user.Username,
HashedEmail: string(encryption.Hash([]byte(user.Email))),
Data: string(bin),
}
res := DB.Model(&DeletedUserMemory{}).Clauses(
clause.OnConflict{
Columns: []clause.Column{
{Name: "username"},
{Name: "hashed_email"},
},
UpdateAll: true,
},
).Create(m)
return res.Error
}
// RestoreDeletedUserMemory checks for remembered data and will restore and clear the memory if found.
func RestoreDeletedUserMemory(user *User) error {
// Anything stored?
var (
m *DeletedUserMemory
hashedEmail = string(encryption.Hash([]byte(user.Email)))
err = DB.Model(&DeletedUserMemory{}).Where(
"username = ? OR hashed_email = ?",
user.Username,
hashedEmail,
).First(&m).Error
)
if err != nil {
return nil
}
// Parse the remembered payload.
var data DeletedUserMemoryData
err = json.Unmarshal([]byte(m.Data), &data)
if err != nil {
return fmt.Errorf("RestoreDeletedUserMemory: JSON unmarshal: %s", err)
}
// Bulk restore the user's block list.
blocks, err := BulkRestoreBlockedUserIDs(user, data.BlockingUserIDs, data.BlockedByUserIDs)
if err != nil {
log.Error("BulkRestoreBlockedUserIDs(%s): %s", user.Username, err)
}
// If any blocks were added, notify the admin that the user has returned - for visibility
// and to detect any mistakes.
if blocks > 0 {
fb := &Feedback{
Intent: "report",
Subject: "A deleted user has returned",
UserID: user.ID,
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"The username **@%s**, who was previously deleted, has signed up a new account "+
"with the same username or e-mail address.\n\n"+
"Their previous username was **@%s** when they last deleted their account.\n\n"+
"Their block lists from when they deleted their old account have been restored:\n\n"+
"* Forward list: %d\n* Reverse list: %d",
user.Username,
data.PreviousUsername,
len(data.BlockingUserIDs),
len(data.BlockedByUserIDs),
),
}
// Save the feedback.
if err := CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user recovering their deleted account: %s", err)
}
}
// Delete the stored user memory.
return DB.Where(
"username = ? OR hashed_email = ?",
user.Username,
hashedEmail,
).Delete(&DeletedUserMemory{}).Error
}

View File

@ -13,6 +13,12 @@ import (
func DeleteUser(user *models.User) error {
log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username)
// Store the user's block lists in case they come back soon under the same email address
// or username.
if err := models.CreateDeletedUserMemory(user); err != nil {
log.Error("DeleteUser(%s): CreateDeletedUserMemory: %s", user.Username, err)
}
// Clear their history on the chat room.
go func() {
i, err := chat.EraseChatHistory(user.Username)

View File

@ -42,6 +42,7 @@ func AutoMigrate() {
// Non-user or persistent data.
&AdminScope{},
&DeletedUserMemory{},
&Forum{},
// Vendor/misc data.

View File

@ -207,7 +207,7 @@
{{end}}
<div class="field">
<button type="submit" class="button is-primary">Continue and verify email</button>
<button type="submit" class="button is-primary">Continue{{if not .SignupToken}} and verify email{{end}}</button>
</div>
</form>
</div>