From 90d0d10ee5dbfb3364316be2e9f1cd90c05c2b40 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Fri, 23 Aug 2024 21:21:42 -0700 Subject: [PATCH] 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 --- pkg/config/enum.go | 1 + pkg/controller/index/contact.go | 2 +- pkg/models/deletion/delete_user.go | 34 +++++++++++++++ pkg/models/models.go | 65 ++++++++++++++++------------ pkg/models/poll.go | 37 ++++++++++++++++ pkg/models/thread.go | 15 +++++-- pkg/worker/vacuum.go | 46 +++++++++++++++----- web/templates/contact.html | 13 ++++++ web/templates/forum/admin.html | 10 +++-- web/templates/forum/board_index.html | 33 +++++++++++--- 10 files changed, 204 insertions(+), 52 deletions(-) diff --git a/pkg/config/enum.go b/pkg/config/enum.go index 8036be4..f02717b 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -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"}, }, }, } diff --git a/pkg/controller/index/contact.go b/pkg/controller/index/contact.go index 90b637e..2dd6abe 100644 --- a/pkg/controller/index/contact.go +++ b/pkg/controller/index/contact.go @@ -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. diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go index 17f9451..3cb15f6 100644 --- a/pkg/models/deletion/delete_user.go +++ b/pkg/models/deletion/delete_user.go @@ -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 +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 38c37de..679558a 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -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{}, + ) } diff --git a/pkg/models/poll.go b/pkg/models/poll.go index 66eb715..ee49ada 100644 --- a/pkg/models/poll.go +++ b/pkg/models/poll.go @@ -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 +} diff --git a/pkg/models/thread.go b/pkg/models/thread.go index cd5986d..a4e02c9 100644 --- a/pkg/models/thread.go +++ b/pkg/models/thread.go @@ -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 } diff --git a/pkg/worker/vacuum.go b/pkg/worker/vacuum.go index 3bc8a7e..ef46b8d 100644 --- a/pkg/worker/vacuum.go +++ b/pkg/worker/vacuum.go @@ -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() diff --git a/web/templates/contact.html b/web/templates/contact.html index f45477e..85e3083 100644 --- a/web/templates/contact.html +++ b/web/templates/contact.html @@ -52,11 +52,24 @@ + + {{if eq .Subject "forum.adopt"}} +

+ You may use this form to petition to adopt 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. +

+ +

+ Including a message here is highly encouraged. +

+ {{else}}

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.

+ {{end}}
diff --git a/web/templates/forum/admin.html b/web/templates/forum/admin.html index e2ee4c8..5e52a31 100644 --- a/web/templates/forum/admin.html +++ b/web/templates/forum/admin.html @@ -138,12 +138,14 @@
-

{{.Title}}

+

+ {{.Title}} +

- /f/{{.Fragment}} + /f/{{.Fragment}} {{if .Category}}{{.Category}}{{end}} - - by {{.Owner.Username}} + + by @{{.Owner.Username}} {{if .Owner.IsAdmin}}{{end}}

diff --git a/web/templates/forum/board_index.html b/web/templates/forum/board_index.html index dd9aed2..6d6e458 100644 --- a/web/templates/forum/board_index.html +++ b/web/templates/forum/board_index.html @@ -234,15 +234,17 @@ - {{template "avatar-16x16" .Forum.Owner}} - - {{.Forum.Owner.Username}} - - (owner) + {{if .Forum.OwnerID}} + {{template "avatar-16x16" .Forum.Owner}} + + {{.Forum.Owner.Username}} + + (owner) + {{end}} {{range .ForumModerators}} - + {{template "avatar-16x16" .}} {{.Username}} @@ -250,7 +252,26 @@ {{end}} + + {{if not .Forum.OwnerID}} + + {{end}} +
+ + {{if not .Forum.OwnerID}} + + {{end}} +