Add admin user list

This enables admins on multi-user instances to see all users registered,
and view the details of each, including:

- Username
- Join date
- Total posts
- Last post date
- All blogs
  - Public info
  - Views
  - Total posts
  - Last post date
  - Fediverse followers count

This is the foundation for future user moderation features.

Ref T553
This commit is contained in:
Matt Baer 2019-01-04 22:28:29 -05:00
parent 2f4c93cccb
commit 0e722de82c
9 changed files with 312 additions and 0 deletions

101
admin.go
View file

@ -70,6 +70,12 @@ type systemStatus struct {
NumGC uint32
}
type inspectedCollection struct {
CollectionObj
Followers int
LastPost string
}
func handleViewAdminDash(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
updateAppStats()
p := struct {
@ -104,6 +110,101 @@ func handleViewAdminDash(app *app, u *User, w http.ResponseWriter, r *http.Reque
return nil
}
func handleViewAdminUsers(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
Config config.AppCfg
Message string
Users *[]User
}{
UserPage: NewUserPage(app, r, u, "Users", nil),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
var err error
p.Users, err = app.db.GetAllUsers(1)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get users: %v", err)}
}
showUserPage(w, "users", p)
return nil
}
func handleViewAdminUser(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
p := struct {
*UserPage
Config config.AppCfg
Message string
User *User
Colls []inspectedCollection
LastPost string
TotalPosts int64
}{
Config: app.cfg.App,
Message: r.FormValue("m"),
Colls: []inspectedCollection{},
}
var err error
p.User, err = app.db.GetUserForAuth(username)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)}
}
p.UserPage = NewUserPage(app, r, u, p.User.Username, nil)
p.TotalPosts = app.db.GetUserPostsCount(p.User.ID)
lp, err := app.db.GetUserLastPostTime(p.User.ID)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's last post time: %v", err)}
}
if lp != nil {
p.LastPost = lp.Format("January 2, 2006, 3:04 PM")
}
colls, err := app.db.GetCollections(p.User)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)}
}
for _, c := range *colls {
ic := inspectedCollection{
CollectionObj: CollectionObj{Collection: c},
}
if app.cfg.App.Federation {
folls, err := app.db.GetAPFollowers(&c)
if err == nil {
// TODO: handle error here (at least log it)
ic.Followers = len(*folls)
}
}
app.db.GetPostsCount(&ic.CollectionObj, true)
lp, err := app.db.GetCollectionLastPostTime(c.ID)
if err != nil {
log.Error("Didn't get last post time for collection %d: %v", c.ID, err)
}
if lp != nil {
ic.LastPost = lp.Format("January 2, 2006, 3:04 PM")
}
p.Colls = append(p.Colls, ic)
}
showUserPage(w, "view-user", p)
return nil
}
func handleAdminUpdateSite(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
id := vars["page"]

View file

@ -112,6 +112,9 @@ type writestore interface {
GetDynamicContent(id string) (string, *time.Time, error)
UpdateDynamicContent(id, content string) error
GetAllUsers(page uint) (*[]User, error)
GetUserLastPostTime(id int64) (*time.Time, error)
GetCollectionLastPostTime(id int64) (*time.Time, error)
}
type datastore struct {
@ -1740,6 +1743,20 @@ func (db *datastore) GetUserPosts(u *User) (*[]PublicPost, error) {
return &posts, nil
}
func (db *datastore) GetUserPostsCount(userID int64) int64 {
var count int64
err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ?", userID).Scan(&count)
switch {
case err == sql.ErrNoRows:
return 0
case err != nil:
log.Error("Failed selecting posts count for user %d: %v", userID, err)
return 0
}
return count
}
// ChangeSettings takes a User and applies the changes in the given
// userSettings, MODIFYING THE USER with successful changes.
func (db *datastore) ChangeSettings(app *app, u *User, s *userSettings) error {
@ -2202,6 +2219,59 @@ func (db *datastore) UpdateDynamicContent(id, content string) error {
return err
}
func (db *datastore) GetAllUsers(page uint) (*[]User, error) {
const usersPerPage = 30
limitStr := fmt.Sprintf("0, %d", usersPerPage)
if page > 1 {
limitStr = fmt.Sprintf("%d, %d", page*usersPerPage, page*usersPerPage+usersPerPage)
}
rows, err := db.Query("SELECT id, username, created FROM users ORDER BY created DESC LIMIT " + limitStr)
if err != nil {
log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user posts."}
}
defer rows.Close()
users := []User{}
for rows.Next() {
u := User{}
err = rows.Scan(&u.ID, &u.Username, &u.Created)
if err != nil {
log.Error("Failed scanning GetAllUsers() row: %v", err)
break
}
users = append(users, u)
}
return &users, nil
}
func (db *datastore) GetUserLastPostTime(id int64) (*time.Time, error) {
var t time.Time
err := db.QueryRow("SELECT created FROM posts WHERE owner_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
log.Error("Failed selecting last post time from posts: %v", err)
return nil, err
}
return &t, nil
}
func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
var t time.Time
err := db.QueryRow("SELECT created FROM posts WHERE collection_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
log.Error("Failed selecting last post time from posts: %v", err)
return nil, err
}
return &t, nil
}
func stringLogln(log *string, s string, v ...interface{}) {
*log += fmt.Sprintf(s+"\n", v...)
}

View file

@ -2,3 +2,10 @@
font-size: 1em;
min-height: 12em;
}
header.admin {
margin: 0;
h1 + a {
margin-left: 1em;
}
}

View file

@ -126,6 +126,8 @@ func initRoutes(handler *Handler, r *mux.Router, cfg *config.Config, db *datasto
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
write.HandleFunc("/admin/update/config", handler.Admin(handleAdminUpdateConfig)).Methods("POST")
write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")

View file

@ -49,6 +49,7 @@ function savePage(el) {
<ul class="pagenav">
{{if not .SingleUser}}
<li><a href="/admin/users">View Users</a></li>
<li><a href="#page-about">Edit About page</a></li>
<li><a href="#page-privacy">Edit Privacy page</a></li>
{{end}}

View file

@ -0,0 +1,27 @@
{{define "users"}}
{{template "header" .}}
<div class="snug content-container">
{{template "admin-header" .}}
<h2 id="posts-header">Users</h2>
<table class="classy export">
<tr>
<th>User</th>
<th>Joined</th>
<th>Type</th>
</tr>
{{range .Users}}
<tr>
<td><a href="/admin/user/{{.Username}}">{{.Username}}</a></td>
<td>{{.CreatedFriendly}}</td>
<td style="text-align:center">{{if .IsAdmin}}Admin{{else}}User{{end}}</td>
</tr>
{{end}}
</table>
</div>
{{template "footer" .}}
{{end}}

View file

@ -0,0 +1,88 @@
{{define "view-user"}}
{{template "header" .}}
<style>
table.classy th {
text-align: left;
}
h3 {
font-weight: normal;
}
</style>
<div class="snug content-container">
{{template "admin-header" .}}
<p><a href="/admin/users">View Users</a></p>
<h2 id="posts-header">{{.User.Username}}</h2>
<table class="classy export">
<tr>
<th>No.</th>
<td>{{.User.ID}}</td>
</tr>
<tr>
<th>Type</th>
<td>{{if .User.IsAdmin}}Admin{{else}}User{{end}}</td>
</tr>
<tr>
<th>Username</th>
<td>{{.User.Username}}</td>
</tr>
<tr>
<th>Joined</th>
<td>{{.User.CreatedFriendly}}</td>
</tr>
<tr>
<th>Total Posts</th>
<td>{{.TotalPosts}}</td>
</tr>
<tr>
<th>Last Post</th>
<td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td>
</tr>
</table>
<h2>Blogs</h2>
{{range .Colls}}
<h3><a href="/{{.Alias}}/">{{.Title}}</a></h3>
<table class="classy export">
<tr>
<th>Alias</th>
<td>{{.Alias}}</td>
</tr>
<tr>
<th>Title</th>
<td>{{.Title}}</td>
</tr>
<tr>
<th>Description</th>
<td>{{.Description}}</td>
</tr>
<tr>
<th>Visibility</th>
<td>{{.FriendlyVisibility}}</td>
</tr>
<tr>
<th>Views</th>
<td>{{.Views}}</td>
</tr>
<tr>
<th>Posts</th>
<td>{{.TotalPosts}}</td>
</tr>
<tr>
<th>Last Post</th>
<td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td>
</tr>
{{if $.Config.Federation}}
<tr>
<th>Fediverse Followers</th>
<td>{{.Followers}}</td>
</tr>
{{end}}
</table>
{{end}}
</div>
{{template "footer" .}}
{{end}}

View file

@ -57,3 +57,10 @@
</header>
<div id="official-writing">
{{end}}
{{define "admin-header"}}
<header class="admin">
<h1>Admin</h1>
<a href="/admin">back to dashboard</a>
</header>
{{end}}

View file

@ -95,6 +95,15 @@ func (u *User) EmailClear(keys *keychain) string {
return ""
}
func (u User) CreatedFriendly() string {
/*
// TODO: accept a locale in this method and use that for the format
var loc monday.Locale = monday.LocaleEnUS
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
*/
return u.Created.Format("January 2, 2006, 3:04 PM")
}
// Cookie strips down an AuthUser to contain only information necessary for
// cookies.
func (u User) Cookie() *User {