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 b8146ae485
commit 06ae20cb3e
10 changed files with 204 additions and 52 deletions

View File

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

View File

@ -83,7 +83,7 @@ func Contact() http.HandlerFunc {
} else {
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
}
case "report.forum":
case "report.forum", "forum.adopt":
tableName = "forums"
// 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
// to remove the "safest" fields first.
var todo = []remover{
{"Admin group memberships", DeleteAdminGroupUsers},
{"Disown User Forums", DisownForums},
{"Notifications", DeleteNotifications},
{"Likes", DeleteLikes},
{"Threads", DeleteForumThreads},
@ -56,6 +58,7 @@ func DeleteUser(user *models.User) error {
{"Change Logs", DeleteChangeLogs},
{"IP Addresses", DeleteIPAddresses},
{"Push Notifications", DeletePushNotifications},
{"Forum Memberships", DeleteForumMemberships},
}
for _, item := range todo {
if err := item.Fn(user.ID); err != nil {
@ -67,6 +70,16 @@ func DeleteUser(user *models.User) error {
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.
func DeleteUserPhotos(userID uint64) error {
log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID)
@ -372,3 +385,24 @@ func DeletePushNotifications(userID uint64) error {
).Delete(&models.PushNotification{})
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.
func AutoMigrate() {
DB.AutoMigrate(&User{})
DB.AutoMigrate(&ProfileField{})
DB.AutoMigrate(&Photo{})
DB.AutoMigrate(&PrivatePhoto{})
DB.AutoMigrate(&CertificationPhoto{})
DB.AutoMigrate(&Message{})
DB.AutoMigrate(&Friend{})
DB.AutoMigrate(&Block{})
DB.AutoMigrate(&Feedback{})
DB.AutoMigrate(&Forum{})
DB.AutoMigrate(&Thread{})
DB.AutoMigrate(&Comment{})
DB.AutoMigrate(&Like{})
DB.AutoMigrate(&Notification{})
DB.AutoMigrate(&Subscription{})
DB.AutoMigrate(&CommentPhoto{})
DB.AutoMigrate(&Poll{})
DB.AutoMigrate(&PollVote{})
DB.AutoMigrate(&AdminGroup{})
DB.AutoMigrate(&AdminScope{})
DB.AutoMigrate(&UserLocation{})
DB.AutoMigrate(&UserNote{})
DB.AutoMigrate(&TwoFactor{})
DB.AutoMigrate(&ChangeLog{})
DB.AutoMigrate(&IPAddress{})
DB.AutoMigrate(&PushNotification{})
DB.AutoMigrate(&WorldCities{})
DB.AutoMigrate(&ForumMembership{})
DB.AutoMigrate(
// User and user-generated data.
// ✔ = models are cleaned up on DeleteUser()
&AdminGroup{}, // ✔ admin_group_users
&Block{}, // ✔
&CertificationPhoto{}, // ✔
&ChangeLog{}, // ✔
&Comment{}, // ✔
&CommentPhoto{}, // ✔
&Feedback{}, // ✔
&ForumMembership{}, // ✔
&Friend{}, // ✔
&IPAddress{}, // ✔
&Like{}, // ✔
&Message{}, // ✔
&Notification{}, // ✔
&ProfileField{}, // ✔
&Photo{}, // ✔
&PollVote{}, // keep their vote on polls
&Poll{}, // vacuum script cleans up orphaned polls
&PrivatePhoto{}, // ✔
&PushNotification{}, // ✔
&Thread{}, // ✔
&TwoFactor{}, // ✔
&Subscription{}, // ✔
&User{}, // ✔
&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
}
// 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.
type PollResult struct {
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 {
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.
result := DB.Where(
if result := DB.Where(
"table_name = ? AND table_id = ?",
"threads", t.ID,
).Delete(&Comment{})
if result.Error != nil {
).Delete(&Comment{}); result.Error != nil {
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.
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.
func Vacuum(dryrun bool) error {
log.Warn("Vacuum: Orphaned Comment Photos")
if total, err := VacuumOrphanedCommentPhotos(dryrun); err != nil {
log.Error("Orphaned Comment Photos: %s", err)
} else {
log.Info("Removed %d photo(s)", total)
var steps = []struct {
Label string
Fn func(bool) (int64, error)
}{
{"Comment Photos", VacuumOrphanedCommentPhotos},
{"Photos", VacuumOrphanedPhotos},
{"Polls", VacuumOrphanedPolls},
}
log.Warn("Vacuum: Orphaned Gallery Photos")
if total, err := VacuumOrphanedPhotos(dryrun); err != nil {
log.Error("Orphaned Gallery Photos: %s", err)
} else {
log.Info("Removed %d photo(s)", total)
for _, step := range steps {
log.Warn("Vacuum: %s", step.Label)
if total, err := step.Fn(dryrun); err != nil {
log.Error("%s: %s", step.Label, err)
} else {
log.Info("Removed %d rows", total)
}
}
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.
func VacuumOrphanedPhotos(dryrun bool) (int64, error) {
photos, count, err := models.GetOrphanedPhotos()

View File

@ -52,11 +52,24 @@
<input type="hidden" name="intent" value="{{.Intent}}">
<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">
You may use this form to contact the site administrators to provide
feedback, criticism, or to report a problem you have found on the
site such as inappropriate content posted by one of our members.
</p>
{{end}}
<div class="field block">
<label for="subject" class="label">Subject</label>

View File

@ -138,12 +138,14 @@
<div class="box">
<div class="columns">
<div class="column">
<h1 class="title">{{.Title}}</h1>
<h1 class="title">
<a href="/f/{{.Fragment}}">{{.Title}}</a>
</h1>
<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}}
<span class="ml-4">
by <strong><a href="/u/{{.Owner.Username}}">{{.Owner.Username}}</a></strong>
<span class="ml-4 is-size-6">
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}}
</span>
</h2>

View File

@ -234,15 +234,17 @@
<label class="label"><i class="fa fa-user-tie"></i> Forum Moderators</label>
<!-- The owner first -->
{{template "avatar-16x16" .Forum.Owner}}
<a href="/u/{{.Forum.Owner.Username}}">
{{.Forum.Owner.Username}}
</a>
<small class="has-text-grey">(owner)</small>
{{if .Forum.OwnerID}}
{{template "avatar-16x16" .Forum.Owner}}
<a href="/u/{{.Forum.Owner.Username}}">
{{.Forum.Owner.Username}}
</a>
<small class="has-text-grey pr-2">(owner)</small>
{{end}}
<!-- Additional moderators -->
{{range .ForumModerators}}
<span class="pl-2">
<span class="pr-2">
{{template "avatar-16x16" .}}
<a href="/u/{{.Username}}">
{{.Username}}
@ -250,7 +252,26 @@
</span>
{{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">
<!-- 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">
<a href="/contact?intent=report&subject=report.forum&id={{.Forum.ID}}" class="has-text-danger">
<i class="fa fa-flag"></i> Report this forum