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}} +
+ + Admin Actions +
++ {{if eq .Intent "impersonate"}} + + Impersonate User + {{else if eq .Intent "ban"}} + + Ban User + {{else if eq .Intent "promote"}} + + Promote User + {{else if eq .Intent "delete"}} + + Delete User + {{end}} +
+{{or .User.Name "(no name)"}}
++ + {{.User.Username}} +
+