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}}