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
main
Noah Petherbridge 2024-02-25 17:03:36 -08:00
parent 85d2f4eee9
commit f4d176a538
18 changed files with 749 additions and 9 deletions

View File

@ -15,7 +15,8 @@ var (
PageSizePrivatePhotoGrantees = 12 PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20 PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20 PageSizeAdminFeedback = 20
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
PageSizeChangeLog = 20
PageSizeAdminUserNotes = 10 // other users' notes PageSizeAdminUserNotes = 10 // other users' notes
PageSizeSiteGallery = 16 PageSizeSiteGallery = 16
PageSizeUserGallery = 16 PageSizeUserGallery = 16

View File

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

View File

@ -24,6 +24,14 @@ func MarkPhotoExplicit() http.HandlerFunc {
next = "/" 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 { if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil {
photoID = uint64(idInt) photoID = uint64(idInt)
} else { } else {
@ -46,6 +54,12 @@ func MarkPhotoExplicit() http.HandlerFunc {
} else { } else {
session.Flash(w, r, "Marked photo as Explicit!") 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) templates.Redirect(w, next)
}) })
} }

View File

@ -97,6 +97,9 @@ func BlockUser() http.HandlerFunc {
session.FlashError(w, r, "Couldn't unblock this user: %s.", err) session.FlashError(w, r, "Couldn't unblock this user: %s.", err)
} else { } else {
session.Flash(w, r, "You have removed %s from your block list.", user.Username) 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") templates.Redirect(w, "/users/blocked")
return return
@ -139,6 +142,9 @@ func BlockUser() http.HandlerFunc {
session.FlashError(w, r, "Couldn't block this user: %s.", err) session.FlashError(w, r, "Couldn't block this user: %s.", err)
} else { } else {
session.Flash(w, r, "You have added %s to your block list.", user.Username) 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. // Sync the block to the BareRTC chat server now, in case either user is currently online.

View File

@ -117,6 +117,9 @@ func PostComment() http.HandlerFunc {
session.FlashError(w, r, "Error deleting your commenting: %s", err) session.FlashError(w, r, "Error deleting your commenting: %s", err)
} else { } else {
session.Flash(w, r, "Your comment has been deleted.") 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) templates.Redirect(w, fromURL)
return return
@ -151,6 +154,9 @@ func PostComment() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save comment: %s", err) session.FlashError(w, r, "Couldn't save comment: %s", err)
} else { } else {
session.Flash(w, r, "Comment updated!") 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) templates.Redirect(w, fromURL)
return return
@ -168,6 +174,9 @@ func PostComment() http.HandlerFunc {
session.Flash(w, r, "Comment added!") session.Flash(w, r, "Comment added!")
templates.Redirect(w, fromURL) 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. // Notify the recipient of the comment.
if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) { if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) {
notif := &models.Notification{ notif := &models.Notification{

View File

@ -1,6 +1,7 @@
package forum package forum
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -74,6 +75,16 @@ func AddEdit() http.HandlerFunc {
// Were we editing an existing forum? // Were we editing an existing forum?
if forum != nil { 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.Title = title
forum.Description = description forum.Description = description
forum.Category = category forum.Category = category
@ -86,6 +97,9 @@ func AddEdit() http.HandlerFunc {
if err := forum.Save(); err == nil { if err := forum.Save(); err == nil {
session.Flash(w, r, "Forum has been updated!") session.Flash(w, r, "Forum has been updated!")
templates.Redirect(w, "/forum/admin") templates.Redirect(w, "/forum/admin")
// Log the change.
models.LogUpdated(currentUser, nil, "forums", forum.ID, "Updated the forum's settings.", diffs)
return return
} else { } else {
session.FlashError(w, r, "Error saving the forum: %s", err) 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 { if err := models.CreateForum(forum); err == nil {
session.Flash(w, r, "The forum has been created!") session.Flash(w, r, "The forum has been created!")
templates.Redirect(w, "/forum/admin") 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 return
} else { } else {
session.FlashError(w, r, "Error creating the forum: %s", err) session.FlashError(w, r, "Error creating the forum: %s", err)

View File

@ -161,6 +161,11 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Error deleting your post: %s", err) session.FlashError(w, r, "Error deleting your post: %s", err)
} else { } else {
session.Flash(w, r, "Your post has been deleted.") 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)) templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return return
@ -315,6 +320,14 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save comment: %s", err) session.FlashError(w, r, "Couldn't save comment: %s", err)
} else { } else {
session.Flash(w, r, "Comment updated!") 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)) templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return return
@ -327,6 +340,13 @@ func NewPost() http.HandlerFunc {
} else { } else {
session.Flash(w, r, "Reply added to the thread!") 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 we're attaching a photo, link it to this reply CommentID.
if commentPhoto != nil { if commentPhoto != nil {
commentPhoto.CommentID = reply.ID 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)) templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return return
} }

View File

@ -75,6 +75,12 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, message) session.Flash(w, r, message)
if verdict == "reject" { if verdict == "reject" {
templates.Redirect(w, "/friends?view=requests") 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") templates.Redirect(w, "/friends")
return return
@ -85,6 +91,9 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You have ignored the friend request from %s.", username) session.Flash(w, r, "You have ignored the friend request from %s.", username)
} }
templates.Redirect(w, "/friends") templates.Redirect(w, "/friends")
// Log the change.
models.LogUpdated(currentUser, nil, "friends", user.ID, "Ignored the friend request from "+user.Username+".", nil)
return return
} else { } else {
// Post the friend request. // Post the friend request.
@ -106,7 +115,13 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You accepted the friend request from %s!", username) session.Flash(w, r, "You accepted the friend request from %s!", username)
templates.Redirect(w, "/friends?view=requests") templates.Redirect(w, "/friends?view=requests")
// Log the change.
models.LogUpdated(currentUser, nil, "friends", user.ID, "Accepted friend request from "+user.Username+".", nil)
return 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!") session.Flash(w, r, "Friend request sent!")
} }

View File

@ -87,6 +87,9 @@ func Certification() http.HandlerFunc {
session.FlashError(w, r, "Error saving your User data: %s", err) 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.") session.Flash(w, r, "Your certification photo has been deleted.")
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
return return
@ -151,6 +154,9 @@ func Certification() http.HandlerFunc {
log.Error("Certification: failed to notify admins of pending photo: %s", err) 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.") session.Flash(w, r, "Your certification photo has been uploaded and is now awaiting approval.")
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
return return
@ -297,6 +303,9 @@ func AdminCertification() http.HandlerFunc {
user.Certified = false user.Certified = false
user.Save() 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? // Did we silently ignore it?
if comment == "(ignore)" { if comment == "(ignore)" {
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.") 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) 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!") session.Flash(w, r, "Certification photo approved!")
default: default:
session.FlashError(w, r, "Unsupported verdict option: %s", verdict) session.FlashError(w, r, "Unsupported verdict option: %s", verdict)

View File

@ -42,6 +42,10 @@ func Edit() http.HandlerFunc {
return 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? // Do we have permission for this photo?
if photo.UserID != currentUser.ID { if photo.UserID != currentUser.ID {
if !currentUser.IsAdmin { if !currentUser.IsAdmin {
@ -85,6 +89,14 @@ func Edit() http.HandlerFunc {
isGallery = false 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.Caption = caption
photo.Explicit = isExplicit photo.Explicit = isExplicit
photo.Gallery = isGallery photo.Gallery = isGallery
@ -134,6 +146,9 @@ func Edit() http.HandlerFunc {
// Flash success. // Flash success.
session.Flash(w, r, "Photo settings updated!") 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 this picture has moved to Private, revoke any notification we gave about it before.
if goingPrivate || goingCircle { if goingPrivate || goingCircle {
log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility) 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 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? // Do we have permission for this photo?
if photo.UserID != currentUser.ID { if photo.UserID != currentUser.ID {
if !currentUser.IsAdmin { if !currentUser.IsAdmin {
@ -248,6 +267,9 @@ func Delete() http.HandlerFunc {
return return
} }
// Log the change.
models.LogDeleted(currentUser, requestUser, "photos", photo.ID, "Deleted the photo.", photo)
session.Flash(w, r, "Photo deleted!") session.Flash(w, r, "Photo deleted!")
// Return the user to their gallery. // Return the user to their gallery.

View File

@ -2,6 +2,7 @@ package photo
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
@ -159,6 +160,19 @@ func Upload() http.HandlerFunc {
user.Save() 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. // Notify all of our friends that we posted a new picture.
go notifyFriendsNewPhoto(p, user) go notifyFriendsNewPhoto(p, user)

234
pkg/models/change_log.go Normal file
View File

@ -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 <code>%s</code> from <code>%s</code>",
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
}

View File

@ -16,7 +16,7 @@ type Comment struct {
TableName string `gorm:"index"` TableName string `gorm:"index"`
TableID uint64 `gorm:"index"` TableID uint64 `gorm:"index"`
UserID uint64 `gorm:"index"` UserID uint64 `gorm:"index"`
User User User User `json:"-"`
Message string Message string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time

View File

@ -31,4 +31,5 @@ func AutoMigrate() {
DB.AutoMigrate(&UserLocation{}) DB.AutoMigrate(&UserLocation{})
DB.AutoMigrate(&UserNote{}) DB.AutoMigrate(&UserNote{})
DB.AutoMigrate(&TwoFactor{}) DB.AutoMigrate(&TwoFactor{})
DB.AutoMigrate(&ChangeLog{})
} }

View File

@ -17,15 +17,15 @@ import (
// User account table. // User account table.
type User struct { type User struct {
ID uint64 `gorm:"primaryKey"` ID uint64 `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex"` Username string `gorm:"uniqueIndex"`
Email string `gorm:"uniqueIndex"` Email string `gorm:"uniqueIndex" json:"-"`
HashedPassword string HashedPassword string `json:"-"`
IsAdmin bool `gorm:"index"` IsAdmin bool `gorm:"index"`
Status UserStatus `gorm:"index"` // active, disabled Status UserStatus `gorm:"index"` // active, disabled
Visibility UserVisibility `gorm:"index"` // public, private Visibility UserVisibility `gorm:"index"` // public, private
Name *string Name *string
Birthdate time.Time Birthdate time.Time `json:"-"`
Certified bool Certified bool
Explicit bool `gorm:"index"` // user has opted-in to see explicit content Explicit bool `gorm:"index"` // user has opted-in to see explicit content
InnerCircle bool `gorm:"index"` // user is in the inner circle InnerCircle bool `gorm:"index"` // user is in the inner circle
@ -34,10 +34,10 @@ type User struct {
LastLoginAt time.Time `gorm:"index"` LastLoginAt time.Time `gorm:"index"`
// Relational tables. // Relational tables.
ProfileField []ProfileField ProfileField []ProfileField `json:"-"`
ProfilePhotoID *uint64 ProfilePhotoID *uint64
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"` 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. // Current user's relationship to this user -- not stored in DB.
UserRelationship UserRelationship `gorm:"-"` UserRelationship UserRelationship `gorm:"-"`

View File

@ -101,6 +101,7 @@ func New() http.Handler {
mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit())) mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit()))
mux.Handle("/inner-circle/remove", middleware.LoginRequired(account.RemoveCircle())) mux.Handle("/inner-circle/remove", middleware.LoginRequired(account.RemoveCircle()))
mux.Handle("/admin/photo/mark-explicit", middleware.AdminRequired(config.ScopePhotoModerator, admin.MarkPhotoExplicit())) mux.Handle("/admin/photo/mark-explicit", middleware.AdminRequired(config.ScopePhotoModerator, admin.MarkPhotoExplicit()))
mux.Handle("GET /admin/changelog", middleware.AdminRequired("", admin.ChangeLog()))
// JSON API endpoints. // JSON API endpoints.
mux.HandleFunc("GET /v1/version", api.Version()) mux.HandleFunc("GET /v1/version", api.Version())

View File

@ -0,0 +1,212 @@
{{define "title"}}Change Log{{end}}
{{define "content"}}
<div class="container">
{{$Root := .}}
<section class="hero is-danger is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Change Logs
</h1>
</div>
</div>
</section>
<form action="{{.Request.URL.Path}}" method="GET">
<div class="p-4">
<div class="columns">
<div class="column">
Found {{FormatNumberCommas .Pager.Total}} user{{Pluralize64 .Pager.Total}}
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</div>
</div>
<div class="block">
<div class="card nonshy-collapsible-mobile">
<header class="card-header has-background-link-light">
<p class="card-header-title">
Search Filters
</p>
<button class="card-header-icon" type="button">
<span class="icon">
<i class="fa fa-angle-up"></i>
</span>
</button>
</header>
<div class="card-content">
<div class="columns">
<div class="column pr-1">
<div class="field">
<label class="label">Table Name:</label>
<div class="select is-fullwidth">
<select id="table_name" name="table_name">
<option value="">(Any)</option>
{{range .TableNames}}
<option value="{{.}}"{{if eq $Root.TableName .}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</div>
<div class="column px-1">
<div class="field">
<label class="label">Table ID:</label>
<input type="number" class="input"
name="table_id"
autocomplete="off"
value="{{.TableID}}">
</div>
</div>
<div class="column px-1">
<div class="field">
<label class="label">About User:</label>
<input type="text" class="input"
name="about_user_id"
autocomplete="off"
value="{{.AboutUserID}}">
<p class="help">
ID number <em>or</em> username or email address.
</p>
</div>
</div>
<div class="column px-1">
<div class="field">
<label class="label">Admin User:</label>
<input type="text" class="input"
name="admin_user_id"
autocomplete="off"
value="{{.AdminUserID}}">
<p class="help">
ID number <em>or</em> username or email address.
</p>
</div>
</div>
<div class="column px-1">
<div class="field">
<label class="label">Event Type:</label>
<div class="select is-fullwidth">
<select id="event" name="event">
<option value="">(Any)</option>
{{range .EventTypes}}
<option value="{{.}}"{{if eq $Root.Event .}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</div>
<div class="column px-1">
<div class="field">
<label class="label">Sort:</label>
<div class="select is-fullwidth">
<select id="sort" name="sort">
<option value="created_at desc"{{if eq $Root.Sort "created_at desc"}} selected{{end}}>Newest first</option>
<option value="created_at asc"{{if eq $Root.Sort "created_at asc"}} selected{{end}}>Oldest first</option>
</select>
</div>
</div>
</div>
</div>
<div class="has-text-centered">
<a href="{{.Request.URL.Path}}" class="button">Reset</a>
<button type="submit" class="button is-success">
<span>Search</span>
<span class="icon"><i class="fa fa-search"></i></span>
</button>
</div>
</div>
</div>
</div>
{{SimplePager .Pager}}
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>Event</th>
<th>About&nbsp;User</th>
<th>Admin&nbsp;User</th>
<th>Table</th>
<th>ID</th>
<th>Message</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody class="is-size-7">
{{range .ChangeLog}}
<tr>
<td>
<a href="{{$Root.Request.URL.Path}}?{{QueryPlus "event" .Event}}">{{.Event}}</a>
</td>
<td>
{{if $Root.UserMap.Has .AboutUserID}}
{{$User := $Root.UserMap.Get .AboutUserID}}
<a href="/u/{{$User.Username}}">{{$User.Username}}</a>
<small>(ID: {{$User.ID}})</small>
<!-- Filter by this user -->
<a href="{{$Root.Request.URL.Path}}?{{QueryPlus "about_user_id" .AboutUserID}}">
<i class="fa fa-search" title="Filter by this user ID"></i>
</a>
{{else}}
{{.AboutUserID}}
<i class="fa fa-exclamation-triangle has-text-danger" title="User Not Found" onclick="alert('User Not Found')"></i>
{{end}}
</td>
<td>
{{if $Root.UserMap.Has .AdminUserID}}
{{$User := $Root.UserMap.Get .AdminUserID}}
<a href="/u/{{$User.Username}}">{{$User.Username}}</a>
<small>(ID: {{$User.ID}})</small>
<!-- Filter by this user -->
<a href="{{$Root.Request.URL.Path}}?{{QueryPlus "admin_user_id" .AdminUserID}}">
<i class="fa fa-search" title="Filter by this user ID"></i>
</a>
{{else if .AdminUserID}}
{{.AdminUserID}}
<i class="fa fa-exclamation-triangle has-text-danger" title="User Not Found" onclick="alert('User Not Found')"></i>
{{end}}
</td>
<td>
<a href="{{$Root.Request.URL.Path}}?{{QueryPlus "table_name" .TableName}}">{{.TableName}}</a>
</td>
<td>
<a href="{{$Root.Request.URL.Path}}?{{QueryPlus "table_name" .TableName "table_id" .TableID}}">{{.TableID}}</a>
</td>
<td>
<div class="content">
{{ToMarkdown .Message}}
</div>
</td>
<td>
{{.CreatedAt.Format "2006-01-02 15:04:05"}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{SimplePager .Pager}}
</div>
</form>
</div>
<!-- Style override, allow a horizontal scrollbar if needed, e.g. mobile -->
<style>
body {
overflow-x: auto;
}
</style>
{{end}}

View File

@ -142,6 +142,12 @@
Gallery: Admin View Gallery: Admin View
</a> </a>
</li> </li>
<li>
<a href="/admin/changelog">
<i class="fa fa-clipboard-list mr-2"></i>
Change Log Viewer
</a>
</li>
<li> <li>
<a href="/admin/scopes"> <a href="/admin/scopes">
<i class="fa fa-gavel mr-2"></i> <i class="fa fa-gavel mr-2"></i>