From f4d176a5388e8aba446fbeb3f3883bd4f8b0309f Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 25 Feb 2024 17:03:36 -0800 Subject: [PATCH] Change Logs * Add a ChangeLog table to collect historic updates to various database tables. * Created, Updated (with field diffs) and Deleted actions are logged, as well as certification photo approves/denies. * Specific items added to the change log: * When a user photo is marked Explicit by an admin * When users block/unblock each other * When photo comments are posted, edited, and deleted * When forums are created, edited, and deleted * When forum comments are created, edited and deleted * When a new forum thread is created * When a user uploads or removes their own certification photo * When an admin approves or rejects a certification photo * When a user uploads, modifies or deletes their gallery photos * When a friend request is sent * When a friend request is accepted, ignored, or rejected * When a friendship is removed --- pkg/config/page_sizes.go | 3 +- pkg/controller/admin/change_log.go | 125 +++++++++++++ pkg/controller/admin/user_actions.go | 14 ++ pkg/controller/block/block.go | 6 + pkg/controller/comment/post_comment.go | 9 + pkg/controller/forum/add_edit.go | 36 ++++ pkg/controller/forum/new_post.go | 32 ++++ pkg/controller/friend/request.go | 15 ++ pkg/controller/photo/certification.go | 12 ++ pkg/controller/photo/edit_delete.go | 22 +++ pkg/controller/photo/upload.go | 14 ++ pkg/models/change_log.go | 234 +++++++++++++++++++++++++ pkg/models/comment.go | 2 +- pkg/models/models.go | 1 + pkg/models/user.go | 14 +- pkg/router/router.go | 1 + web/templates/admin/change_log.html | 212 ++++++++++++++++++++++ web/templates/admin/dashboard.html | 6 + 18 files changed, 749 insertions(+), 9 deletions(-) create mode 100644 pkg/controller/admin/change_log.go create mode 100644 pkg/models/change_log.go create mode 100644 web/templates/admin/change_log.html 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"}} +
+ {{$Root := .}} +
+
+
+

+ Change Logs +

+
+
+
+ +
+
+ +
+
+ Found {{FormatNumberCommas .Pager.Total}} user{{Pluralize64 .Pager.Total}} + (page {{.Pager.Page}} of {{.Pager.Pages}}). +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+
+
+ +
+
+ + +
+
+ +
+
+ + +

+ ID number or username or email address. +

+
+
+ +
+
+ + +

+ ID number or username or email address. +

+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+ +
+
+
+ +
+ +
+ Reset + +
+
+
+ +
+ + {{SimplePager .Pager}} + + + + + + + + + + + + + + + {{range .ChangeLog}} + + + + + + + + + + {{end}} + +
EventAbout UserAdmin UserTableIDMessageTimestamp
+ {{.Event}} + + {{if $Root.UserMap.Has .AboutUserID}} + {{$User := $Root.UserMap.Get .AboutUserID}} + {{$User.Username}} + (ID: {{$User.ID}}) + + + + + + {{else}} + {{.AboutUserID}} + + {{end}} + + {{if $Root.UserMap.Has .AdminUserID}} + {{$User := $Root.UserMap.Get .AdminUserID}} + {{$User.Username}} + (ID: {{$User.ID}}) + + + + + + {{else if .AdminUserID}} + {{.AdminUserID}} + + {{end}} + + {{.TableName}} + + {{.TableID}} + +
+ {{ToMarkdown .Message}} +
+
+ {{.CreatedAt.Format "2006-01-02 15:04:05"}} +
+ + {{SimplePager .Pager}} + +
+
+
+ + + +{{end}} diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index 949372a..d9b1aff 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -142,6 +142,12 @@ Gallery: Admin View +
  • + + + Change Log Viewer + +