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
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

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 = "/"
}
// 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)
})
}

View File

@ -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.

View File

@ -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{

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

@ -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
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"`
TableID uint64 `gorm:"index"`
UserID uint64 `gorm:"index"`
User User
User User `json:"-"`
Message string
CreatedAt time.Time
UpdatedAt time.Time

View File

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

View File

@ -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:"-"`

View File

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

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
</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>