diff --git a/pkg/controller/account/login.go b/pkg/controller/account/login.go index 438c1a6..bc5d67c 100644 --- a/pkg/controller/account/login.go +++ b/pkg/controller/account/login.go @@ -56,6 +56,13 @@ func Login() http.HandlerFunc { return } + // Is their account banned or disabled? + if user.Status != models.UserStatusActive { + session.FlashError(w, r, "Your account has been %s. If you believe this was done in error, please contact support.", user.Status) + templates.Redirect(w, r.URL.Path) + return + } + // OK. Log in the user's session. session.LoginUser(w, r, user) diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index d51d90f..a94513e 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -37,6 +37,12 @@ func Profile() http.HandlerFunc { return } + // Banned or disabled? Only admin can view then. + if user.Status != models.UserStatusActive && !currentUser.IsAdmin { + templates.NotFoundPage(w, r) + return + } + vars := map[string]interface{}{ "User": user, "IsFriend": models.FriendStatus(currentUser.ID, user.ID), diff --git a/pkg/controller/admin/user_actions.go b/pkg/controller/admin/user_actions.go new file mode 100644 index 0000000..26beb43 --- /dev/null +++ b/pkg/controller/admin/user_actions.go @@ -0,0 +1,126 @@ +package admin + +import ( + "net/http" + "strconv" + + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/models/deletion" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Admin actions against a user account. +func UserActions() http.HandlerFunc { + tmpl := templates.Must("admin/user_actions.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + intent = r.FormValue("intent") + confirm = r.Method == http.MethodPost + userId uint64 + ) + + // 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("user_id")); err == nil { + userId = uint64(idInt) + } else { + session.FlashError(w, r, "Invalid or missing user_id parameter: %s", err) + templates.Redirect(w, "/admin") + return + } + + // Get this user. + user, err := models.GetUser(userId) + if err != nil { + session.FlashError(w, r, "Didn't find user ID in database: %s", err) + templates.Redirect(w, "/admin") + return + } + + switch intent { + case "impersonate": + if confirm { + if err := session.ImpersonateUser(w, r, user, currentUser); err != nil { + session.FlashError(w, r, "Failed to impersonate user: %s", err) + } else { + session.Flash(w, r, "You are now impersonating %s", user.Username) + templates.Redirect(w, "/me") + return + } + } + case "ban": + if confirm { + status := r.PostFormValue("status") + if status == "active" { + user.Status = models.UserStatusActive + } else if status == "banned" { + user.Status = models.UserStatusBanned + } + + user.Save() + session.Flash(w, r, "User ban status updated!") + templates.Redirect(w, "/u/"+user.Username) + return + } + case "promote": + if confirm { + action := r.PostFormValue("action") + user.IsAdmin = action == "promote" + user.Save() + session.Flash(w, r, "User admin status updated!") + templates.Redirect(w, "/u/"+user.Username) + return + } + case "delete": + if confirm { + if err := deletion.DeleteUser(user); err != nil { + session.FlashError(w, r, "Failed when deleting the user: %s", err) + } else { + session.Flash(w, r, "User has been deleted!") + } + templates.Redirect(w, "/admin") + return + } + default: + session.FlashError(w, r, "Unsupported admin user intent: %s", intent) + templates.Redirect(w, "/admin") + return + } + + var vars = map[string]interface{}{ + "Intent": intent, + "User": user, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} + +// Un-impersonate a user account. +func Unimpersonate() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess := session.Get(r) + if sess.Impersonator > 0 { + user, err := models.GetUser(sess.Impersonator) + if err != nil { + session.FlashError(w, r, "Couldn't unimpersonate: impersonator (%d) is not an admin!", user.ID) + templates.Redirect(w, "/") + return + } + + session.LoginUser(w, r, user) + session.Flash(w, r, "No longer impersonating.") + templates.Redirect(w, "/") + } + templates.Redirect(w, "/") + }) +} diff --git a/pkg/controller/photo/certification.go b/pkg/controller/photo/certification.go index 70a342c..ad484ea 100644 --- a/pkg/controller/photo/certification.go +++ b/pkg/controller/photo/certification.go @@ -167,6 +167,40 @@ func Certification() http.HandlerFunc { func AdminCertification() http.HandlerFunc { tmpl := templates.Must("admin/certification.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // View status + var view = r.FormValue("view") + if view == "" { + view = "pending" + } + + // Short circuit the GET view for username/email search (exact match) + if username := r.FormValue("username"); username != "" { + user, err := models.FindUser(username) + if err != nil { + session.FlashError(w, r, "Username or email '%s' not found.", username) + templates.Redirect(w, r.URL.Path) + return + } + + cert, err := models.GetCertificationPhoto(user.ID) + if err != nil { + session.FlashError(w, r, "Couldn't get their certification photo: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + var vars = map[string]interface{}{ + "View": view, + "Photos": []*models.CertificationPhoto{cert}, + "UserMap": &models.UserMap{user.ID: user}, + "FoundUser": user, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + // Making a verdict? if r.Method == http.MethodPost { var ( @@ -275,7 +309,7 @@ func AdminCertification() http.HandlerFunc { Sort: "updated_at desc", } pager.ParsePage(r) - photos, err := models.CertificationPhotosNeedingApproval(models.CertificationPhotoPending, pager) + photos, err := models.CertificationPhotosNeedingApproval(models.CertificationPhotoStatus(view), pager) if err != nil { session.FlashError(w, r, "Couldn't load certification photos from DB: %s", err) } @@ -291,6 +325,7 @@ func AdminCertification() http.HandlerFunc { } var vars = map[string]interface{}{ + "View": view, "Photos": photos, "UserMap": userMap, "Pager": pager, diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go index a3983a7..bf6e9b2 100644 --- a/pkg/middleware/authentication.go +++ b/pkg/middleware/authentication.go @@ -7,6 +7,7 @@ import ( "git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/controller/photo" "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" ) @@ -24,6 +25,19 @@ func LoginRequired(handler http.Handler) http.Handler { return } + // Are they banned or disabled? + if user.Status == models.UserStatusDisabled { + session.LogoutUser(w, r) + session.FlashError(w, r, "Your account has been disabled and you are now logged out.") + templates.Redirect(w, "/") + return + } else if user.Status == models.UserStatusBanned { + session.LogoutUser(w, r) + session.FlashError(w, r, "Your account has been banned and you are now logged out.") + templates.Redirect(w, "/") + return + } + // Ping LastLoginAt for long lived sessions. if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown { user.LastLoginAt = time.Now() diff --git a/pkg/models/certification.go b/pkg/models/certification.go index 646eabe..fd3bd24 100644 --- a/pkg/models/certification.go +++ b/pkg/models/certification.go @@ -63,6 +63,13 @@ func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager * return p, result.Error } +// CountCertificationPhotosNeedingApproval gets the count of pending photos for admin alert. +func CountCertificationPhotosNeedingApproval() int64 { + var count int64 + DB.Where("status = ?", CertificationPhotoPending).Model(&CertificationPhoto{}).Count(&count) + return count +} + // Save photo. func (p *CertificationPhoto) Save() error { result := DB.Save(p) diff --git a/pkg/models/user.go b/pkg/models/user.go index 2f31d3b..a9ba2c9 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -47,6 +47,7 @@ type UserStatus string const ( UserStatusActive = "active" UserStatusDisabled = "disabled" + UserStatusBanned = "banned" ) // CreateUser. It is assumed username and email are correctly formatted. diff --git a/pkg/router/router.go b/pkg/router/router.go index 8dec359..9985614 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -41,6 +41,7 @@ func New() http.Handler { mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose())) mux.Handle("/friends", middleware.LoginRequired(friend.Friends())) mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend())) + mux.Handle("/admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate())) // Certification Required. Pages that only full (verified) members can access. mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery())) @@ -49,6 +50,7 @@ func New() http.Handler { // Admin endpoints. mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification())) + mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions())) // JSON API endpoints. mux.HandleFunc("/v1/version", api.Version()) diff --git a/pkg/session/session.go b/pkg/session/session.go index 7315c63..1603cf2 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -16,12 +16,13 @@ import ( // Session cookie object that is kept server side in Redis. type Session struct { - UUID string `json:"-"` // not stored - LoggedIn bool `json:"loggedIn"` - UserID uint64 `json:"userId,omitempty"` - Flashes []string `json:"flashes,omitempty"` - Errors []string `json:"errors,omitempty"` - LastSeen time.Time `json:"lastSeen"` + UUID string `json:"-"` // not stored + LoggedIn bool `json:"loggedIn"` + UserID uint64 `json:"userId,omitempty"` + Flashes []string `json:"flashes,omitempty"` + Errors []string `json:"errors,omitempty"` + Impersonator uint64 `json:"impersonator,omitempty"` + LastSeen time.Time `json:"lastSeen"` } const ( @@ -88,6 +89,7 @@ func (s *Session) Save(w http.ResponseWriter) { Name: config.SessionCookieName, Value: s.UUID, MaxAge: config.SessionCookieMaxAge, + Path: "/", HttpOnly: true, } http.SetCookie(w, cookie) @@ -144,6 +146,7 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error { sess := Get(r) sess.LoggedIn = true sess.UserID = u.ID + sess.Impersonator = 0 sess.Save(w) // Ping the user's last login time. @@ -151,6 +154,32 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error { return u.Save() } +// ImpersonateUser assumes the role of the user impersonated by an admin uid. +func ImpersonateUser(w http.ResponseWriter, r *http.Request, u *models.User, impersonator *models.User) error { + if u == nil || u.ID == 0 { + return errors.New("not a valid user account") + } + if impersonator == nil || impersonator.ID == 0 || !impersonator.IsAdmin { + return errors.New("impersonator not a valid admin account") + } + + sess := Get(r) + sess.LoggedIn = true + sess.UserID = u.ID + sess.Impersonator = impersonator.ID + sess.Save(w) + + // Ping the user's last login time. + u.LastLoginAt = time.Now() + return u.Save() +} + +// Impersonated returns if the current session has an impersonator. +func Impersonated(r *http.Request) bool { + sess := Get(r) + return sess.Impersonator > 0 +} + // LogoutUser signs a user out. func LogoutUser(w http.ResponseWriter, r *http.Request) { sess := Get(r) diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index 4ea5018..2b1aaa0 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -30,11 +30,16 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { m["CurrentUser"] = nil m["NavUnreadMessages"] = 0 m["NavFriendRequests"] = 0 + m["NavAdminNotifications"] = 0 // total count of admin notifications for nav + m["NavCertificationPhotos"] = 0 // admin indicator for certification photos + m["SessionImpersonated"] = false if r == nil { return } + m["SessionImpersonated"] = session.Impersonated(r) + if user, err := session.CurrentUser(r); err == nil { m["LoggedIn"] = true m["CurrentUser"] = user @@ -52,5 +57,13 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { } else { log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err) } + + // Are we admin? + if user.IsAdmin { + // Any pending certification photos? + m["NavCertificationPhotos"] = models.CountCertificationPhotosNeedingApproval() + } } + + m["NavAdminNotifications"] = m["NavCertificationPhotos"] } diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 653cc62..229c745 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -111,6 +111,14 @@ Log out + {{if .SessionImpersonated}} +
  • + + + Unimpersonate + +
  • + {{end}}
  • diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index c339f6a..7c15bdc 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -34,6 +34,11 @@ {{.User.Username}} {{end}} + {{if ne .User.Status "active"}} +

    + ({{.User.Status}}) +

    + {{end}}
    @@ -305,6 +310,47 @@
    + + + {{if .CurrentUser.IsAdmin}} +
    +
    +

    + + Admin Actions +

    +
    + +
    +
    + {{end}} diff --git a/web/templates/admin/certification.html b/web/templates/admin/certification.html index 63a1db0..687dc60 100644 --- a/web/templates/admin/certification.html +++ b/web/templates/admin/certification.html @@ -1,5 +1,6 @@ {{define "title"}}Admin - Certification Photos{{end}} {{define "content"}} +{{$Root := .}}
    @@ -12,11 +13,69 @@
    -
    - There {{Pluralize64 .Pager.Total "is" "are"}} {{.Pager.Total}} Certification Photo{{Pluralize64 .Pager.Total}} needing approval. +
    +
    + {{if .Pager}} + There {{Pluralize64 .Pager.Total "is" "are"}} {{.Pager.Total}} Certification Photo{{Pluralize64 .Pager.Total}} + {{if eq .View "pending"}} + needing approval. + {{else}} + at status "{{.View}}." + {{end}} + {{else if .FoundUser}} + Found user {{.FoundUser.Username}} + ({{.FoundUser.Email}}) + {{end}} +
    +
    +
    + +
    +
    - {{$Root := .}} +
    +
    +
    +
    Search username or email:
    + +
    +
    +
    + + {{if .Pager}} + + {{end}} +
    {{range .Photos}}
    @@ -57,21 +116,45 @@
    +
    +
    +
    + Status: +
    +
    + {{if eq .Status "pending"}} + Pending Approval + {{else if eq .Status "approved"}} + Approved + {{else if eq .Status "rejected"}} + Rejected + {{else}} + {{.Status}} + {{end}} +
    +
    +
    +
    + placeholder="Admin comment (for rejection)">{{.AdminComment}}
    + {{if not (eq .Status "rejected")}} + {{end}} + + {{if not (eq .Status "approved")}} + {{end}}
    diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index 30c5cdc..cebfec7 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -26,6 +26,7 @@ Certification Photos + {{if .NavCertificationPhotos}}{{.NavCertificationPhotos}}{{end}}
  • diff --git a/web/templates/admin/user_actions.html b/web/templates/admin/user_actions.html new file mode 100644 index 0000000..dea1118 --- /dev/null +++ b/web/templates/admin/user_actions.html @@ -0,0 +1,166 @@ +{{define "title"}}Compose a Message{{end}} +{{define "content"}} +
    +
    +
    +
    +

    + Admin Action +

    +

    On user {{.User.Username}}

    +
    +
    +
    + +
    +
    +
    + +
    + +
    + +
    +
    +
    + {{if .User.ProfilePhoto.ID}} + + {{else}} + + {{end}} +
    +
    +
    +

    {{or .User.Name "(no name)"}}

    +

    + + {{.User.Username}} +

    +
    +
    + +
    + {{InputCSRF}} + + + + {{if eq .Intent "impersonate"}} +
    +

    With great power...

    +

    + By impersonating this user, you will be considered as "logged in" + to their account and have access to their messages, profile, photos and settings. +

    +

    + Please respect user privacy and only impersonate an account as needed to diagnose + a customer support issue or similar. +

    +
    + +
    + +
    + {{else if eq .Intent "ban"}} +
    +

    + This user is currently: + {{if eq .User.Status "active"}} + Active (not banned) + {{else if eq .User.Status "disabled"}} + Disabled + {{else if eq .User.Status "banned"}} + Banned + {{end}} +

    + +

    + Select a new status for them below: +

    +
    + +
    + + +
    + {{else if eq .Intent "promote"}} +
    +

    + This user is currently: + {{if .User.IsAdmin}} + Admin + {{else}} + NOT Admin + {{end}} +

    + +

    + Select a new status for them below: +

    +
    + +
    + + +
    + {{else if eq .Intent "delete"}} +
    +

    + Click the button below to deep delete this user account. +

    +
    + +
    + +
    + {{end}} +
    + +
    +
    + +
    +
    +
    + +
    + + +{{end}} \ No newline at end of file diff --git a/web/templates/base.html b/web/templates/base.html index 5e0d011..60928e7 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -116,7 +116,10 @@ {{end}} -
    {{.CurrentUser.Username}}
    +
    + {{.CurrentUser.Username}} + {{if .NavAdminNotifications}}{{.NavAdminNotifications}}{{end}} +
    @@ -127,7 +130,16 @@ Upload Photo Settings {{if .CurrentUser.IsAdmin}} - Admin + + Admin + {{if .NavAdminNotifications}}{{.NavAdminNotifications}}{{end}} + + {{end}} + {{if .SessionImpersonated}} + + + Unimpersonate + {{end}} Log out