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:
parent
b8146ae485
commit
06ae20cb3e
|
@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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{},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue
Block a user