Adopt a Forum

* Forums are disowned on user account deletion (their owner_id=0)
* A forum without an owner shows a notice at the bottom with a link to petition
  to adopt the forum. It goes to the Contact form with a special subject.
  * Note: there is no easy way to re-assign ownership yet other than a direct
    database query.
* Code cleanup
  * Alphabetize the DB.AutoMigrate tables.
  * Delete more things on user deletion: forum_memberships, admin_group_users
  * Vacuum worker to clean up orphaned polls after the threads are removed
This commit is contained in:
Noah Petherbridge 2024-08-23 21:21:42 -07:00
parent b12390563e
commit 90d0d10ee5
10 changed files with 204 additions and 52 deletions

View File

@ -100,6 +100,7 @@ var (
{"report.message", "Report a direct message conversation"}, {"report.message", "Report a direct message conversation"},
{"report.comment", "Report a forum post or comment"}, {"report.comment", "Report a forum post or comment"},
{"report.forum", "Report a forum or community"}, {"report.forum", "Report a forum or community"},
{"forum.adopt", "Adopt a forum"},
}, },
}, },
} }

View File

@ -83,7 +83,7 @@ func Contact() http.HandlerFunc {
} else { } else {
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err) log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
} }
case "report.forum": case "report.forum", "forum.adopt":
tableName = "forums" tableName = "forums"
// Find this forum. // Find this forum.

View File

@ -36,6 +36,8 @@ func DeleteUser(user *models.User) error {
// Tables to remove. In case of any unexpected DB errors, these tables are ordered // Tables to remove. In case of any unexpected DB errors, these tables are ordered
// to remove the "safest" fields first. // to remove the "safest" fields first.
var todo = []remover{ var todo = []remover{
{"Admin group memberships", DeleteAdminGroupUsers},
{"Disown User Forums", DisownForums},
{"Notifications", DeleteNotifications}, {"Notifications", DeleteNotifications},
{"Likes", DeleteLikes}, {"Likes", DeleteLikes},
{"Threads", DeleteForumThreads}, {"Threads", DeleteForumThreads},
@ -56,6 +58,7 @@ func DeleteUser(user *models.User) error {
{"Change Logs", DeleteChangeLogs}, {"Change Logs", DeleteChangeLogs},
{"IP Addresses", DeleteIPAddresses}, {"IP Addresses", DeleteIPAddresses},
{"Push Notifications", DeletePushNotifications}, {"Push Notifications", DeletePushNotifications},
{"Forum Memberships", DeleteForumMemberships},
} }
for _, item := range todo { for _, item := range todo {
if err := item.Fn(user.ID); err != nil { if err := item.Fn(user.ID); err != nil {
@ -67,6 +70,16 @@ func DeleteUser(user *models.User) error {
return user.Delete() return user.Delete()
} }
// DeleteAdminGroupUsers scrubs data for deleting a user.
func DeleteAdminGroupUsers(userID uint64) error {
log.Error("DeleteUser: DeleteAdminGroupUsers(%d)", userID)
result := models.DB.Exec(
"DELETE FROM admin_group_users WHERE user_id = ?",
userID,
)
return result.Error
}
// DeleteUserPhotos scrubs data for deleting a user. // DeleteUserPhotos scrubs data for deleting a user.
func DeleteUserPhotos(userID uint64) error { func DeleteUserPhotos(userID uint64) error {
log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID) log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID)
@ -372,3 +385,24 @@ func DeletePushNotifications(userID uint64) error {
).Delete(&models.PushNotification{}) ).Delete(&models.PushNotification{})
return result.Error return result.Error
} }
// DisownForums unlinks the user from their owned forums.
func DisownForums(userID uint64) error {
log.Error("DeleteUser: DisownForums(%d)", userID)
result := models.DB.Exec(`
UPDATE forums
SET owner_id = NULL
WHERE owner_id = ?
`, userID)
return result.Error
}
// DeleteForumMemberships scrubs data for deleting a user.
func DeleteForumMemberships(userID uint64) error {
log.Error("DeleteUser: DeleteForumMemberships(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.ForumMembership{})
return result.Error
}

View File

@ -8,32 +8,41 @@ var DB *gorm.DB
// AutoMigrate the schema. // AutoMigrate the schema.
func AutoMigrate() { func AutoMigrate() {
DB.AutoMigrate(&User{})
DB.AutoMigrate(&ProfileField{}) DB.AutoMigrate(
DB.AutoMigrate(&Photo{}) // User and user-generated data.
DB.AutoMigrate(&PrivatePhoto{}) // ✔ = models are cleaned up on DeleteUser()
DB.AutoMigrate(&CertificationPhoto{}) &AdminGroup{}, // ✔ admin_group_users
DB.AutoMigrate(&Message{}) &Block{}, // ✔
DB.AutoMigrate(&Friend{}) &CertificationPhoto{}, // ✔
DB.AutoMigrate(&Block{}) &ChangeLog{}, // ✔
DB.AutoMigrate(&Feedback{}) &Comment{}, // ✔
DB.AutoMigrate(&Forum{}) &CommentPhoto{}, // ✔
DB.AutoMigrate(&Thread{}) &Feedback{}, // ✔
DB.AutoMigrate(&Comment{}) &ForumMembership{}, // ✔
DB.AutoMigrate(&Like{}) &Friend{}, // ✔
DB.AutoMigrate(&Notification{}) &IPAddress{}, // ✔
DB.AutoMigrate(&Subscription{}) &Like{}, // ✔
DB.AutoMigrate(&CommentPhoto{}) &Message{}, // ✔
DB.AutoMigrate(&Poll{}) &Notification{}, // ✔
DB.AutoMigrate(&PollVote{}) &ProfileField{}, // ✔
DB.AutoMigrate(&AdminGroup{}) &Photo{}, // ✔
DB.AutoMigrate(&AdminScope{}) &PollVote{}, // keep their vote on polls
DB.AutoMigrate(&UserLocation{}) &Poll{}, // vacuum script cleans up orphaned polls
DB.AutoMigrate(&UserNote{}) &PrivatePhoto{}, // ✔
DB.AutoMigrate(&TwoFactor{}) &PushNotification{}, // ✔
DB.AutoMigrate(&ChangeLog{}) &Thread{}, // ✔
DB.AutoMigrate(&IPAddress{}) &TwoFactor{}, // ✔
DB.AutoMigrate(&PushNotification{}) &Subscription{}, // ✔
DB.AutoMigrate(&WorldCities{}) &User{}, // ✔
DB.AutoMigrate(&ForumMembership{}) &UserLocation{}, // ✔
&UserNote{}, // ✔
// Non-user or persistent data.
&AdminScope{},
&Forum{},
// Vendor/misc data.
&WorldCities{},
)
} }

View File

@ -115,6 +115,21 @@ func (p *Poll) Save() error {
return result.Error return result.Error
} }
// Delete Poll, which also deletes its PollVotes.
func (p *Poll) Delete() error {
// Delete votes first.
if result := DB.Exec(
"DELETE FROM poll_votes WHERE poll_id = ?",
p.ID,
); result.Error != nil {
return fmt.Errorf("deleting votes: %s", result.Error)
}
result := DB.Delete(p)
return result.Error
}
// PollResult holds metadata about the poll result for frontend display. // PollResult holds metadata about the poll result for frontend display.
type PollResult struct { type PollResult struct {
AcceptingVotes bool // user voted or it expired AcceptingVotes bool // user voted or it expired
@ -133,3 +148,25 @@ func (pr PollResult) GetPercent(answer string) string {
func (pr PollResult) GetClass(answer string) string { func (pr PollResult) GetClass(answer string) string {
return pr.ResultsClass[answer] return pr.ResultsClass[answer]
} }
// GetOrphanedPolls gets all (up to 500) polls that don't have Threads pointing to them.
func GetOrphanedPolls() ([]*Poll, int64, error) {
var (
count int64
ps = []*Poll{}
)
query := DB.Model(&Poll{}).Where(`
NOT EXISTS (
SELECT 1 FROM threads
WHERE threads.poll_id = polls.id
)
`)
query.Count(&count)
res := query.Limit(500).Find(&ps)
if res.Error != nil {
return nil, 0, res.Error
}
return ps, count, res.Error
}

View File

@ -304,14 +304,23 @@ func (t *Thread) Delete() error {
} }
// Remove all comments. // Remove all comments.
result := DB.Where( if result := DB.Where(
"table_name = ? AND table_id = ?", "table_name = ? AND table_id = ?",
"threads", t.ID, "threads", t.ID,
).Delete(&Comment{}) ).Delete(&Comment{}); result.Error != nil {
if result.Error != nil {
return fmt.Errorf("deleting comments for thread: %s", result.Error) return fmt.Errorf("deleting comments for thread: %s", result.Error)
} }
// Remove any polls.
if t.PollID != nil && *t.PollID > 0 {
if result := DB.Exec(
"DELETE FROM polls WHERE id = ?",
t.PollID,
); result.Error != nil {
return fmt.Errorf("deleting poll for thread: %s", result.Error)
}
}
// Remove the thread itself. // Remove the thread itself.
return DB.Delete(t).Error return DB.Delete(t).Error
} }

View File

@ -10,23 +10,49 @@ import (
// Vacuum runs database cleanup tasks for data consistency. Run it like `nonshy vacuum` from the CLI. // Vacuum runs database cleanup tasks for data consistency. Run it like `nonshy vacuum` from the CLI.
func Vacuum(dryrun bool) error { func Vacuum(dryrun bool) error {
log.Warn("Vacuum: Orphaned Comment Photos")
if total, err := VacuumOrphanedCommentPhotos(dryrun); err != nil { var steps = []struct {
log.Error("Orphaned Comment Photos: %s", err) Label string
} else { Fn func(bool) (int64, error)
log.Info("Removed %d photo(s)", total) }{
{"Comment Photos", VacuumOrphanedCommentPhotos},
{"Photos", VacuumOrphanedPhotos},
{"Polls", VacuumOrphanedPolls},
} }
log.Warn("Vacuum: Orphaned Gallery Photos") for _, step := range steps {
if total, err := VacuumOrphanedPhotos(dryrun); err != nil { log.Warn("Vacuum: %s", step.Label)
log.Error("Orphaned Gallery Photos: %s", err) if total, err := step.Fn(dryrun); err != nil {
} else { log.Error("%s: %s", step.Label, err)
log.Info("Removed %d photo(s)", total) } else {
log.Info("Removed %d rows", total)
}
} }
return nil return nil
} }
// VacuumOrphanedPolls removes any polls with forum threads no longer pointing to them.
func VacuumOrphanedPolls(dryrun bool) (int64, error) {
polls, count, err := models.GetOrphanedPolls()
if err != nil {
return count, err
}
if dryrun {
return count, nil
}
for _, row := range polls {
log.Info(" #%d: %s", row.ID, row.Choices)
if err := row.Delete(); err != nil {
return count, fmt.Errorf("deleting orphaned poll (%d): %s", row.ID, err)
}
}
return count, nil
}
// VacuumOrphanedPhotos removes any lingering photo from failed account deletion. // VacuumOrphanedPhotos removes any lingering photo from failed account deletion.
func VacuumOrphanedPhotos(dryrun bool) (int64, error) { func VacuumOrphanedPhotos(dryrun bool) (int64, error) {
photos, count, err := models.GetOrphanedPhotos() photos, count, err := models.GetOrphanedPhotos()

View File

@ -52,11 +52,24 @@
<input type="hidden" name="intent" value="{{.Intent}}"> <input type="hidden" name="intent" value="{{.Intent}}">
<input type="hidden" name="id" value="{{.TableID}}"> <input type="hidden" name="id" value="{{.TableID}}">
<!-- Tailored intro text for certain contact types? -->
{{if eq .Subject "forum.adopt"}}
<p class="block">
You may use this form to <strong>petition to adopt</strong> a forum.
Your request will be reviewed by an admin, and if approved you will be
granted ownership of that forum with the ability to manage it.
</p>
<p class="block">
Including a message here is highly encouraged.
</p>
{{else}}
<p class="block"> <p class="block">
You may use this form to contact the site administrators to provide You may use this form to contact the site administrators to provide
feedback, criticism, or to report a problem you have found on the feedback, criticism, or to report a problem you have found on the
site such as inappropriate content posted by one of our members. site such as inappropriate content posted by one of our members.
</p> </p>
{{end}}
<div class="field block"> <div class="field block">
<label for="subject" class="label">Subject</label> <label for="subject" class="label">Subject</label>

View File

@ -138,12 +138,14 @@
<div class="box"> <div class="box">
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<h1 class="title">{{.Title}}</h1> <h1 class="title">
<a href="/f/{{.Fragment}}">{{.Title}}</a>
</h1>
<h2 class="subtitle"> <h2 class="subtitle">
<a href="/f/{{.Fragment}}">/f/{{.Fragment}}</a> <a href="/f/{{.Fragment}}" class="has-text-grey">/f/{{.Fragment}}</a>
{{if .Category}}<span class="ml-4">{{.Category}}</span>{{end}} {{if .Category}}<span class="ml-4">{{.Category}}</span>{{end}}
<span class="ml-4"> <span class="ml-4 is-size-6">
by <strong><a href="/u/{{.Owner.Username}}">{{.Owner.Username}}</a></strong> by <strong><a href="/u/{{.Owner.Username}}">@{{.Owner.Username}}</a></strong>
{{if .Owner.IsAdmin}}<i class="fa fa-peace has-text-danger ml-1"></i>{{end}} {{if .Owner.IsAdmin}}<i class="fa fa-peace has-text-danger ml-1"></i>{{end}}
</span> </span>
</h2> </h2>

View File

@ -234,15 +234,17 @@
<label class="label"><i class="fa fa-user-tie"></i> Forum Moderators</label> <label class="label"><i class="fa fa-user-tie"></i> Forum Moderators</label>
<!-- The owner first --> <!-- The owner first -->
{{template "avatar-16x16" .Forum.Owner}} {{if .Forum.OwnerID}}
<a href="/u/{{.Forum.Owner.Username}}"> {{template "avatar-16x16" .Forum.Owner}}
{{.Forum.Owner.Username}} <a href="/u/{{.Forum.Owner.Username}}">
</a> {{.Forum.Owner.Username}}
<small class="has-text-grey">(owner)</small> </a>
<small class="has-text-grey pr-2">(owner)</small>
{{end}}
<!-- Additional moderators --> <!-- Additional moderators -->
{{range .ForumModerators}} {{range .ForumModerators}}
<span class="pl-2"> <span class="pr-2">
{{template "avatar-16x16" .}} {{template "avatar-16x16" .}}
<a href="/u/{{.Username}}"> <a href="/u/{{.Username}}">
{{.Username}} {{.Username}}
@ -250,7 +252,26 @@
</span> </span>
{{end}} {{end}}
<!-- If there is no owner, show a petition notice. -->
{{if not .Forum.OwnerID}}
<div class="mt-4 is-size-7">
<i class="fa fa-info-circle has-text-success mr-1"></i>
This forum's owner has deleted their account, and this forum is in need of a new owner.
You may <a href="/contact?intent=contact&subject=forum.adopt&id={{.Forum.ID}}" class="has-text-success">petition to adopt</a>
this forum.
</div>
{{end}}
<div class="columns is-multiline is-mobile mt-4 is-size-7"> <div class="columns is-multiline is-mobile mt-4 is-size-7">
<!-- Adoption link -->
{{if not .Forum.OwnerID}}
<div class="column is-narrow">
<a href="/contact?intent=contact&subject=forum.adopt&id={{.Forum.ID}}" class="has-text-success">
<i class="fa fa-info-circle"></i> Adopt this forum
</a>
</div>
{{end}}
<div class="column is-narrow"> <div class="column is-narrow">
<a href="/contact?intent=report&subject=report.forum&id={{.Forum.ID}}" class="has-text-danger"> <a href="/contact?intent=report&subject=report.forum&id={{.Forum.ID}}" class="has-text-danger">
<i class="fa fa-flag"></i> Report this forum <i class="fa fa-flag"></i> Report this forum