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
b12390563e
commit
90d0d10ee5
|
@ -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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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{},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
log.Error("%s: %s", step.Label, err)
|
||||||
} else {
|
} else {
|
||||||
log.Info("Removed %d photo(s)", total)
|
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()
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 -->
|
||||||
|
{{if .Forum.OwnerID}}
|
||||||
{{template "avatar-16x16" .Forum.Owner}}
|
{{template "avatar-16x16" .Forum.Owner}}
|
||||||
<a href="/u/{{.Forum.Owner.Username}}">
|
<a href="/u/{{.Forum.Owner.Username}}">
|
||||||
{{.Forum.Owner.Username}}
|
{{.Forum.Owner.Username}}
|
||||||
</a>
|
</a>
|
||||||
<small class="has-text-grey">(owner)</small>
|
<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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user