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
This commit is contained in:
parent
85d2f4eee9
commit
f4d176a538
|
@ -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
|
||||
|
|
125
pkg/controller/admin/change_log.go
Normal file
125
pkg/controller/admin/change_log.go
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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!")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
234
pkg/models/change_log.go
Normal file
234
pkg/models/change_log.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -31,4 +31,5 @@ func AutoMigrate() {
|
|||
DB.AutoMigrate(&UserLocation{})
|
||||
DB.AutoMigrate(&UserNote{})
|
||||
DB.AutoMigrate(&TwoFactor{})
|
||||
DB.AutoMigrate(&ChangeLog{})
|
||||
}
|
||||
|
|
|
@ -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:"-"`
|
||||
|
|
|
@ -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())
|
||||
|
|
212
web/templates/admin/change_log.html
Normal file
212
web/templates/admin/change_log.html
Normal 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 User</th>
|
||||
<th>Admin 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}}
|
|
@ -142,6 +142,12 @@
|
|||
Gallery: Admin View
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/changelog">
|
||||
<i class="fa fa-clipboard-list mr-2"></i>
|
||||
Change Log Viewer
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/scopes">
|
||||
<i class="fa fa-gavel mr-2"></i>
|
||||
|
|
Loading…
Reference in New Issue
Block a user