diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go
index 536eeac..d2de4e6 100644
--- a/pkg/config/page_sizes.go
+++ b/pkg/config/page_sizes.go
@@ -15,7 +15,8 @@ var (
PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20
- PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
+ PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
+ PageSizeChangeLog = 20
PageSizeAdminUserNotes = 10 // other users' notes
PageSizeSiteGallery = 16
PageSizeUserGallery = 16
diff --git a/pkg/controller/admin/change_log.go b/pkg/controller/admin/change_log.go
new file mode 100644
index 0000000..c430d11
--- /dev/null
+++ b/pkg/controller/admin/change_log.go
@@ -0,0 +1,125 @@
+package admin
+
+import (
+ "net/http"
+ "strconv"
+
+ "code.nonshy.com/nonshy/website/pkg/config"
+ "code.nonshy.com/nonshy/website/pkg/models"
+ "code.nonshy.com/nonshy/website/pkg/session"
+ "code.nonshy.com/nonshy/website/pkg/templates"
+)
+
+// ChangeLog controller (/admin/changelog)
+func ChangeLog() http.HandlerFunc {
+ tmpl := templates.Must("admin/change_log.html")
+
+ // Whitelist for ordering options.
+ var sortWhitelist = []string{
+ "created_at desc",
+ "created_at asc",
+ }
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Query parameters.
+ var (
+ tableName = r.FormValue("table_name")
+ tableID uint64
+ aboutUserID uint64
+ aboutUser = r.FormValue("about_user_id")
+ adminUserID uint64
+ adminUser = r.FormValue("admin_user_id")
+ event = r.FormValue("event")
+ sort = r.FormValue("sort")
+ sortOK bool
+ )
+
+ // Sort options.
+ for _, v := range sortWhitelist {
+ if sort == v {
+ sortOK = true
+ break
+ }
+ }
+ if !sortOK {
+ sort = "created_at desc"
+ }
+
+ if i, err := strconv.Atoi(r.FormValue("table_id")); err == nil {
+ tableID = uint64(i)
+ }
+
+ // User IDs can be string values to look up by username or email address.
+ if aboutUser != "" {
+ if i, err := strconv.Atoi(aboutUser); err == nil {
+ aboutUserID = uint64(i)
+ } else {
+ if user, err := models.FindUser(aboutUser); err == nil {
+ aboutUserID = user.ID
+ } else {
+ session.FlashError(w, r, "Couldn't find About User ID: %s", err)
+ }
+ }
+ }
+
+ if adminUser != "" {
+ if i, err := strconv.Atoi(adminUser); err == nil {
+ adminUserID = uint64(i)
+ } else {
+ if user, err := models.FindUser(adminUser); err == nil {
+ adminUserID = user.ID
+ } else {
+ session.FlashError(w, r, "Couldn't find Admin User ID: %s", err)
+ }
+ }
+ }
+
+ pager := &models.Pagination{
+ PerPage: config.PageSizeChangeLog,
+ Sort: sort,
+ }
+ pager.ParsePage(r)
+
+ cl, err := models.PaginateChangeLog(tableName, tableID, aboutUserID, adminUserID, event, pager)
+ if err != nil {
+ session.FlashError(w, r, "Error paginating the change log: %s", err)
+ }
+
+ // Map the various user IDs.
+ var (
+ userIDs = []uint64{}
+ )
+ for _, row := range cl {
+ if row.AboutUserID > 0 {
+ userIDs = append(userIDs, row.AboutUserID)
+ }
+ if row.AdminUserID > 0 {
+ userIDs = append(userIDs, row.AdminUserID)
+ }
+ }
+ userMap, err := models.MapUsers(nil, userIDs)
+ if err != nil {
+ session.FlashError(w, r, "Error mapping user IDs: %s", err)
+ }
+
+ var vars = map[string]interface{}{
+ "ChangeLog": cl,
+ "TableNames": models.ChangeLogTables(),
+ "EventTypes": models.ChangeLogEventTypes,
+ "Pager": pager,
+ "UserMap": userMap,
+
+ // Filters
+ "TableName": tableName,
+ "TableID": tableID,
+ "AboutUserID": aboutUser,
+ "AdminUserID": adminUser,
+ "Event": event,
+ "Sort": sort,
+ }
+ if err := tmpl.Execute(w, r, vars); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ })
+}
diff --git a/pkg/controller/admin/user_actions.go b/pkg/controller/admin/user_actions.go
index c0738de..ae1f8c4 100644
--- a/pkg/controller/admin/user_actions.go
+++ b/pkg/controller/admin/user_actions.go
@@ -24,6 +24,14 @@ func MarkPhotoExplicit() http.HandlerFunc {
next = "/"
}
+ // Get current user.
+ currentUser, err := session.CurrentUser(r)
+ if err != nil {
+ session.FlashError(w, r, "Failed to get current user: %s", err)
+ templates.Redirect(w, "/")
+ return
+ }
+
if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil {
photoID = uint64(idInt)
} else {
@@ -46,6 +54,12 @@ func MarkPhotoExplicit() http.HandlerFunc {
} else {
session.Flash(w, r, "Marked photo as Explicit!")
}
+
+ // Log the change.
+ models.LogUpdated(&models.User{ID: photo.UserID}, currentUser, "photos", photo.ID, "Marked explicit by admin action.", []models.FieldDiff{
+ models.NewFieldDiff("Explicit", false, true),
+ })
+
templates.Redirect(w, next)
})
}
diff --git a/pkg/controller/block/block.go b/pkg/controller/block/block.go
index a77ca86..a73e365 100644
--- a/pkg/controller/block/block.go
+++ b/pkg/controller/block/block.go
@@ -97,6 +97,9 @@ func BlockUser() http.HandlerFunc {
session.FlashError(w, r, "Couldn't unblock this user: %s.", err)
} else {
session.Flash(w, r, "You have removed %s from your block list.", user.Username)
+
+ // Log the change.
+ models.LogDeleted(currentUser, nil, "blocks", user.ID, "Unblocked user "+user.Username+".", nil)
}
templates.Redirect(w, "/users/blocked")
return
@@ -139,6 +142,9 @@ func BlockUser() http.HandlerFunc {
session.FlashError(w, r, "Couldn't block this user: %s.", err)
} else {
session.Flash(w, r, "You have added %s to your block list.", user.Username)
+
+ // Log the change.
+ models.LogCreated(currentUser, "blocks", user.ID, "Blocks user "+user.Username+".")
}
// Sync the block to the BareRTC chat server now, in case either user is currently online.
diff --git a/pkg/controller/comment/post_comment.go b/pkg/controller/comment/post_comment.go
index 78e925d..537c293 100644
--- a/pkg/controller/comment/post_comment.go
+++ b/pkg/controller/comment/post_comment.go
@@ -117,6 +117,9 @@ func PostComment() http.HandlerFunc {
session.FlashError(w, r, "Error deleting your commenting: %s", err)
} else {
session.Flash(w, r, "Your comment has been deleted.")
+
+ // Log the change.
+ models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Deleted a comment.", comment)
}
templates.Redirect(w, fromURL)
return
@@ -151,6 +154,9 @@ func PostComment() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save comment: %s", err)
} else {
session.Flash(w, r, "Comment updated!")
+
+ // Log the change.
+ models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Updated a comment.\n\n---\n\n"+comment.Message, nil)
}
templates.Redirect(w, fromURL)
return
@@ -168,6 +174,9 @@ func PostComment() http.HandlerFunc {
session.Flash(w, r, "Comment added!")
templates.Redirect(w, fromURL)
+ // Log the change.
+ models.LogCreated(currentUser, "comments", comment.ID, "Posted a new comment.\n\n---\n\n"+message)
+
// Notify the recipient of the comment.
if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) {
notif := &models.Notification{
diff --git a/pkg/controller/forum/add_edit.go b/pkg/controller/forum/add_edit.go
index d0aa26a..7196b8c 100644
--- a/pkg/controller/forum/add_edit.go
+++ b/pkg/controller/forum/add_edit.go
@@ -1,6 +1,7 @@
package forum
import (
+ "fmt"
"net/http"
"strconv"
"strings"
@@ -74,6 +75,16 @@ func AddEdit() http.HandlerFunc {
// Were we editing an existing forum?
if forum != nil {
+ diffs := []models.FieldDiff{
+ models.NewFieldDiff("Title", forum.Title, title),
+ models.NewFieldDiff("Description", forum.Description, description),
+ models.NewFieldDiff("Category", forum.Category, category),
+ models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
+ models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
+ models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
+ models.NewFieldDiff("InnerCircle", forum.InnerCircle, isInnerCircle),
+ }
+
forum.Title = title
forum.Description = description
forum.Category = category
@@ -86,6 +97,9 @@ func AddEdit() http.HandlerFunc {
if err := forum.Save(); err == nil {
session.Flash(w, r, "Forum has been updated!")
templates.Redirect(w, "/forum/admin")
+
+ // Log the change.
+ models.LogUpdated(currentUser, nil, "forums", forum.ID, "Updated the forum's settings.", diffs)
return
} else {
session.FlashError(w, r, "Error saving the forum: %s", err)
@@ -119,6 +133,28 @@ func AddEdit() http.HandlerFunc {
if err := models.CreateForum(forum); err == nil {
session.Flash(w, r, "The forum has been created!")
templates.Redirect(w, "/forum/admin")
+
+ // Log the change.
+ models.LogCreated(currentUser, "forums", forum.ID, fmt.Sprintf(
+ "Created a new forum.\n\n"+
+ "* Category: %s\n"+
+ "* Title: %s\n"+
+ "* Fragment: %s\n"+
+ "* Description: %s\n"+
+ "* Explicit: %v\n"+
+ "* Privileged: %v\n"+
+ "* Photos: %v\n"+
+ "* Inner Circle: %v",
+ forum.Category,
+ forum.Title,
+ forum.Fragment,
+ forum.Description,
+ forum.Explicit,
+ forum.Privileged,
+ forum.PermitPhotos,
+ forum.InnerCircle,
+ ))
+
return
} else {
session.FlashError(w, r, "Error creating the forum: %s", err)
diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go
index c24cb6a..a42c42f 100644
--- a/pkg/controller/forum/new_post.go
+++ b/pkg/controller/forum/new_post.go
@@ -161,6 +161,11 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Error deleting your post: %s", err)
} else {
session.Flash(w, r, "Your post has been deleted.")
+
+ // Log the change.
+ models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
+ "Deleted a forum comment on thread %d forum /f/%s", thread.ID, forum.Fragment,
+ ), comment)
}
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
@@ -315,6 +320,14 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save comment: %s", err)
} else {
session.Flash(w, r, "Comment updated!")
+
+ // Log the change.
+ models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
+ "Edited their comment on thread %d (in /f/%s):\n\n%s",
+ thread.ID,
+ forum.Fragment,
+ message,
+ ), nil)
}
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
@@ -327,6 +340,13 @@ func NewPost() http.HandlerFunc {
} else {
session.Flash(w, r, "Reply added to the thread!")
+ // Log the change.
+ models.LogCreated(currentUser, "comments", reply.ID, fmt.Sprintf(
+ "Commented on thread %d:\n\n%s",
+ thread.ID,
+ message,
+ ))
+
// If we're attaching a photo, link it to this reply CommentID.
if commentPhoto != nil {
commentPhoto.CommentID = reply.ID
@@ -428,6 +448,18 @@ func NewPost() http.HandlerFunc {
}
}
+ // Log the change.
+ models.LogCreated(currentUser, "threads", thread.ID, fmt.Sprintf(
+ "Started a new forum thread on forum /f/%s (%s)\n\n"+
+ "* Has poll? %v\n"+
+ "* Title: %s\n\n%s",
+ forum.Fragment,
+ forum.Title,
+ isPoll,
+ thread.Title,
+ message,
+ ))
+
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
}
diff --git a/pkg/controller/friend/request.go b/pkg/controller/friend/request.go
index d1fc409..c52a667 100644
--- a/pkg/controller/friend/request.go
+++ b/pkg/controller/friend/request.go
@@ -75,6 +75,12 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, message)
if verdict == "reject" {
templates.Redirect(w, "/friends?view=requests")
+
+ // Log the change.
+ models.LogDeleted(currentUser, nil, "friends", user.ID, "Rejected friend request from "+user.Username+".", nil)
+ } else {
+ // Log the change.
+ models.LogDeleted(currentUser, nil, "friends", user.ID, "Removed friendship with "+user.Username+".", nil)
}
templates.Redirect(w, "/friends")
return
@@ -85,6 +91,9 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You have ignored the friend request from %s.", username)
}
templates.Redirect(w, "/friends")
+
+ // Log the change.
+ models.LogUpdated(currentUser, nil, "friends", user.ID, "Ignored the friend request from "+user.Username+".", nil)
return
} else {
// Post the friend request.
@@ -106,7 +115,13 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You accepted the friend request from %s!", username)
templates.Redirect(w, "/friends?view=requests")
+
+ // Log the change.
+ models.LogUpdated(currentUser, nil, "friends", user.ID, "Accepted friend request from "+user.Username+".", nil)
return
+ } else {
+ // Log the change.
+ models.LogCreated(currentUser, "friends", user.ID, "Sent a friend request to "+user.Username+".")
}
session.Flash(w, r, "Friend request sent!")
}
diff --git a/pkg/controller/photo/certification.go b/pkg/controller/photo/certification.go
index d3bb8cc..a28362d 100644
--- a/pkg/controller/photo/certification.go
+++ b/pkg/controller/photo/certification.go
@@ -87,6 +87,9 @@ func Certification() http.HandlerFunc {
session.FlashError(w, r, "Error saving your User data: %s", err)
}
+ // Log the change.
+ models.LogDeleted(currentUser, nil, "certification_photos", currentUser.ID, "Removed their certification photo.", cert)
+
session.Flash(w, r, "Your certification photo has been deleted.")
templates.Redirect(w, r.URL.Path)
return
@@ -151,6 +154,9 @@ func Certification() http.HandlerFunc {
log.Error("Certification: failed to notify admins of pending photo: %s", err)
}
+ // Log the change.
+ models.LogCreated(currentUser, "certification_photos", currentUser.ID, "Uploaded a new certification photo.")
+
session.Flash(w, r, "Your certification photo has been uploaded and is now awaiting approval.")
templates.Redirect(w, r.URL.Path)
return
@@ -297,6 +303,9 @@ func AdminCertification() http.HandlerFunc {
user.Certified = false
user.Save()
+ // Log the change.
+ models.LogEvent(user, currentUser, models.ChangeLogRejected, "certification_photos", user.ID, "Rejected the certification photo with comment: "+comment)
+
// Did we silently ignore it?
if comment == "(ignore)" {
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.")
@@ -367,6 +376,9 @@ func AdminCertification() http.HandlerFunc {
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
}
+ // Log the change.
+ models.LogEvent(user, currentUser, models.ChangeLogApproved, "certification_photos", user.ID, "Approved the certification photo.")
+
session.Flash(w, r, "Certification photo approved!")
default:
session.FlashError(w, r, "Unsupported verdict option: %s", verdict)
diff --git a/pkg/controller/photo/edit_delete.go b/pkg/controller/photo/edit_delete.go
index ce50df3..fcaed2d 100644
--- a/pkg/controller/photo/edit_delete.go
+++ b/pkg/controller/photo/edit_delete.go
@@ -42,6 +42,10 @@ func Edit() http.HandlerFunc {
return
}
+ // In case an admin is editing this photo: remember the HTTP request current user,
+ // before the currentUser may be set to the photo's owner below.
+ var requestUser = currentUser
+
// Do we have permission for this photo?
if photo.UserID != currentUser.ID {
if !currentUser.IsAdmin {
@@ -85,6 +89,14 @@ func Edit() http.HandlerFunc {
isGallery = false
}
+ // Diff for the ChangeLog.
+ diffs := []models.FieldDiff{
+ models.NewFieldDiff("Caption", photo.Caption, caption),
+ models.NewFieldDiff("Explicit", photo.Explicit, isExplicit),
+ models.NewFieldDiff("Gallery", photo.Gallery, isGallery),
+ models.NewFieldDiff("Visibility", photo.Visibility, visibility),
+ }
+
photo.Caption = caption
photo.Explicit = isExplicit
photo.Gallery = isGallery
@@ -134,6 +146,9 @@ func Edit() http.HandlerFunc {
// Flash success.
session.Flash(w, r, "Photo settings updated!")
+ // Log the change.
+ models.LogUpdated(currentUser, requestUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
+
// If this picture has moved to Private, revoke any notification we gave about it before.
if goingPrivate || goingCircle {
log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility)
@@ -190,6 +205,10 @@ func Delete() http.HandlerFunc {
return
}
+ // In case an admin is editing this photo: remember the HTTP request current user,
+ // before the currentUser may be set to the photo's owner below.
+ var requestUser = currentUser
+
// Do we have permission for this photo?
if photo.UserID != currentUser.ID {
if !currentUser.IsAdmin {
@@ -248,6 +267,9 @@ func Delete() http.HandlerFunc {
return
}
+ // Log the change.
+ models.LogDeleted(currentUser, requestUser, "photos", photo.ID, "Deleted the photo.", photo)
+
session.Flash(w, r, "Photo deleted!")
// Return the user to their gallery.
diff --git a/pkg/controller/photo/upload.go b/pkg/controller/photo/upload.go
index 564ffa6..71111d8 100644
--- a/pkg/controller/photo/upload.go
+++ b/pkg/controller/photo/upload.go
@@ -2,6 +2,7 @@ package photo
import (
"bytes"
+ "fmt"
"io"
"net/http"
"os"
@@ -159,6 +160,19 @@ func Upload() http.HandlerFunc {
user.Save()
}
+ // ChangeLog entry.
+ models.LogCreated(user, "photos", p.ID, fmt.Sprintf(
+ "Uploaded a new photo.\n\n"+
+ "* Caption: %s\n"+
+ "* Visibility: %s\n"+
+ "* Gallery: %v\n"+
+ "* Explicit: %v",
+ p.Caption,
+ p.Visibility,
+ p.Gallery,
+ p.Explicit,
+ ))
+
// Notify all of our friends that we posted a new picture.
go notifyFriendsNewPhoto(p, user)
diff --git a/pkg/models/change_log.go b/pkg/models/change_log.go
new file mode 100644
index 0000000..b7202d8
--- /dev/null
+++ b/pkg/models/change_log.go
@@ -0,0 +1,234 @@
+package models
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "code.nonshy.com/nonshy/website/pkg/log"
+)
+
+// ChangeLog table to track updates to the database.
+type ChangeLog struct {
+ ID uint64 `gorm:"primaryKey"`
+ AboutUserID uint64 `gorm:"index"`
+ AdminUserID uint64 `gorm:"index"` // if an admin edits a user's item
+ TableName string `gorm:"index"`
+ TableID uint64 `gorm:"index"`
+ Event string `gorm:"index"`
+ Message string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// Types of ChangeLog events.
+const (
+ ChangeLogCreated = "created"
+ ChangeLogUpdated = "updated"
+ ChangeLogDeleted = "deleted"
+
+ // Certification photos.
+ ChangeLogApproved = "approved"
+ ChangeLogRejected = "rejected"
+)
+
+var ChangeLogEventTypes = []string{
+ ChangeLogCreated,
+ ChangeLogUpdated,
+ ChangeLogDeleted,
+ ChangeLogApproved,
+ ChangeLogRejected,
+}
+
+// PaginateChangeLog lists the change logs.
+func PaginateChangeLog(tableName string, tableID, aboutUserID, adminUserID uint64, event string, pager *Pagination) ([]*ChangeLog, error) {
+ var (
+ cl = []*ChangeLog{}
+ where = []string{}
+ placeholders = []interface{}{}
+ )
+
+ if tableName != "" {
+ where = append(where, "table_name = ?")
+ placeholders = append(placeholders, tableName)
+ }
+
+ if tableID != 0 {
+ where = append(where, "table_id = ?")
+ placeholders = append(placeholders, tableID)
+ }
+
+ if aboutUserID != 0 {
+ where = append(where, "about_user_id = ?")
+ placeholders = append(placeholders, aboutUserID)
+ }
+
+ if adminUserID != 0 {
+ where = append(where, "admin_user_id = ?")
+ placeholders = append(placeholders, adminUserID)
+ }
+
+ if event != "" {
+ where = append(where, "event = ?")
+ placeholders = append(placeholders, event)
+ }
+
+ query := DB.Model(&ChangeLog{}).Where(
+ strings.Join(where, " AND "),
+ placeholders...,
+ ).Order(
+ pager.Sort,
+ )
+
+ query.Count(&pager.Total)
+ result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cl)
+ return cl, result.Error
+}
+
+// ChangeLogTables returns all the distinct table_names appearing in the change log.
+func ChangeLogTables() []string {
+ var result = []string{}
+
+ query := DB.Model(&ChangeLog{}).
+ Select("DISTINCT change_logs.table_name").
+ Group("change_logs.table_name").
+ Find(&result)
+ if query.Error != nil {
+ log.Error("ChangeLogTables: %s", query.Error)
+ }
+
+ sort.Strings(result)
+
+ return result
+}
+
+// LogEvent puts in a generic/miscellaneous change log event (e.g. certification photo updates).
+func LogEvent(aboutUser, adminUser *User, event, tableName string, tableID uint64, message string) (*ChangeLog, error) {
+ cl := &ChangeLog{
+ TableName: tableName,
+ TableID: tableID,
+ Event: event,
+ Message: message,
+ }
+
+ if aboutUser != nil {
+ cl.AboutUserID = aboutUser.ID
+ }
+
+ if adminUser != nil && adminUser != aboutUser {
+ cl.AdminUserID = adminUser.ID
+ }
+
+ result := DB.Create(cl)
+ return cl, result.Error
+}
+
+// LogCreated puts in a ChangeLog "created" event.
+func LogCreated(aboutUser *User, tableName string, tableID uint64, message string) (*ChangeLog, error) {
+ cl := &ChangeLog{
+ TableName: tableName,
+ TableID: tableID,
+ Event: ChangeLogCreated,
+ Message: message,
+ }
+
+ if aboutUser != nil {
+ cl.AboutUserID = aboutUser.ID
+ }
+
+ result := DB.Create(cl)
+ return cl, result.Error
+}
+
+// LogDeleted puts in a ChangeLog "deleted" event.
+func LogDeleted(aboutUser, adminUser *User, tableName string, tableID uint64, message string, original interface{}) (*ChangeLog, error) {
+ // If the original model is given, JSON serialize it nicely.
+ if original != nil {
+ w := bytes.NewBuffer([]byte{})
+ enc := json.NewEncoder(w)
+ enc.SetIndent("\n", "* ")
+ if err := enc.Encode(original); err != nil {
+ log.Error("LogDeleted(%s %d): couldn't encode original model to JSON: %s", tableName, tableID, err)
+ } else {
+ message += "\n\n" + w.String()
+ }
+ }
+
+ cl := &ChangeLog{
+ TableName: tableName,
+ TableID: tableID,
+ Event: ChangeLogDeleted,
+ Message: message,
+ }
+
+ if aboutUser != nil {
+ cl.AboutUserID = aboutUser.ID
+ }
+
+ if adminUser != nil && adminUser != aboutUser {
+ cl.AdminUserID = adminUser.ID
+ }
+
+ result := DB.Create(cl)
+ return cl, result.Error
+}
+
+type FieldDiff struct {
+ Key string
+ Before interface{}
+ After interface{}
+}
+
+func NewFieldDiff(key string, before, after interface{}) FieldDiff {
+ return FieldDiff{
+ Key: key,
+ Before: before,
+ After: after,
+ }
+}
+
+// LogUpdated puts in a ChangeLog "updated" event.
+func LogUpdated(aboutUser, adminUser *User, tableName string, tableID uint64, message string, diffs []FieldDiff) (*ChangeLog, error) {
+ // Append field diffs to the message?
+ lines := []string{message}
+ if len(diffs) > 0 {
+ lines = append(lines, "")
+ for _, row := range diffs {
+ var (
+ before = fmt.Sprintf("%v", row.Before)
+ after = fmt.Sprintf("%v", row.After)
+ )
+
+ if before != after {
+ lines = append(lines,
+ fmt.Sprintf("* **%s** changed to %s
from %s
",
+ row.Key,
+ strings.ReplaceAll(after, "`", "'"),
+ strings.ReplaceAll(before, "`", "'"),
+ ),
+ )
+ }
+ }
+ }
+
+ cl := &ChangeLog{
+ TableName: tableName,
+ TableID: tableID,
+ Event: ChangeLogUpdated,
+ Message: strings.Join(lines, "\n"),
+ }
+
+ if aboutUser != nil {
+ cl.AboutUserID = aboutUser.ID
+ }
+
+ if adminUser != nil && adminUser != aboutUser {
+ cl.AdminUserID = adminUser.ID
+ }
+
+ result := DB.Create(cl)
+ return cl, result.Error
+}
diff --git a/pkg/models/comment.go b/pkg/models/comment.go
index 31d042a..c71242e 100644
--- a/pkg/models/comment.go
+++ b/pkg/models/comment.go
@@ -16,7 +16,7 @@ type Comment struct {
TableName string `gorm:"index"`
TableID uint64 `gorm:"index"`
UserID uint64 `gorm:"index"`
- User User
+ User User `json:"-"`
Message string
CreatedAt time.Time
UpdatedAt time.Time
diff --git a/pkg/models/models.go b/pkg/models/models.go
index b04d276..197a248 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -31,4 +31,5 @@ func AutoMigrate() {
DB.AutoMigrate(&UserLocation{})
DB.AutoMigrate(&UserNote{})
DB.AutoMigrate(&TwoFactor{})
+ DB.AutoMigrate(&ChangeLog{})
}
diff --git a/pkg/models/user.go b/pkg/models/user.go
index 561030d..f7c70a3 100644
--- a/pkg/models/user.go
+++ b/pkg/models/user.go
@@ -17,15 +17,15 @@ import (
// User account table.
type User struct {
- ID uint64 `gorm:"primaryKey"`
- Username string `gorm:"uniqueIndex"`
- Email string `gorm:"uniqueIndex"`
- HashedPassword string
+ ID uint64 `gorm:"primaryKey"`
+ Username string `gorm:"uniqueIndex"`
+ Email string `gorm:"uniqueIndex" json:"-"`
+ HashedPassword string `json:"-"`
IsAdmin bool `gorm:"index"`
Status UserStatus `gorm:"index"` // active, disabled
Visibility UserVisibility `gorm:"index"` // public, private
Name *string
- Birthdate time.Time
+ Birthdate time.Time `json:"-"`
Certified bool
Explicit bool `gorm:"index"` // user has opted-in to see explicit content
InnerCircle bool `gorm:"index"` // user is in the inner circle
@@ -34,10 +34,10 @@ type User struct {
LastLoginAt time.Time `gorm:"index"`
// Relational tables.
- ProfileField []ProfileField
+ ProfileField []ProfileField `json:"-"`
ProfilePhotoID *uint64
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
- AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;"`
+ AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;" json:"-"`
// Current user's relationship to this user -- not stored in DB.
UserRelationship UserRelationship `gorm:"-"`
diff --git a/pkg/router/router.go b/pkg/router/router.go
index 7e0cfbd..6e7ebcf 100644
--- a/pkg/router/router.go
+++ b/pkg/router/router.go
@@ -101,6 +101,7 @@ func New() http.Handler {
mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit()))
mux.Handle("/inner-circle/remove", middleware.LoginRequired(account.RemoveCircle()))
mux.Handle("/admin/photo/mark-explicit", middleware.AdminRequired(config.ScopePhotoModerator, admin.MarkPhotoExplicit()))
+ mux.Handle("GET /admin/changelog", middleware.AdminRequired("", admin.ChangeLog()))
// JSON API endpoints.
mux.HandleFunc("GET /v1/version", api.Version())
diff --git a/web/templates/admin/change_log.html b/web/templates/admin/change_log.html
new file mode 100644
index 0000000..e2cc474
--- /dev/null
+++ b/web/templates/admin/change_log.html
@@ -0,0 +1,212 @@
+{{define "title"}}Change Log{{end}}
+{{define "content"}}
+