diff --git a/Makefile b/Makefile index b036d87..40e83f7 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,10 @@ setup: build: go build $(LDFLAGS) -o nonshy cmd/nonshy/main.go +.PHONY: test +test: + go test ./... + .PHONY: run run: - go run cmd/nonshy/main.go web --debug \ No newline at end of file + go run cmd/nonshy/main.go web --debug diff --git a/pkg/config/admin_scopes.go b/pkg/config/admin_scopes.go new file mode 100644 index 0000000..c561b57 --- /dev/null +++ b/pkg/config/admin_scopes.go @@ -0,0 +1,66 @@ +package config + +// All available admin scopes +const ( + // Social moderation over the chat and forums. + // - Chat: have operator controls in the chat room + // - Forum: ability to edit and delete user posts + // - Photo: omniscient view of all gallery photos, can edit/delete photos + // - Inner circle: ability to remove users from it + ScopeChatModerator = "social.moderator.chat" + ScopeForumModerator = "social.moderator.forum" + ScopePhotoModerator = "social.moderator.photo" + ScopeCircleModerator = "social.moderator.inner-circle" + + // Certification photo management + // - Approve: ability to respond to pending certification pics + // - List: paginate thru all approved or rejected photos + // - View: inspect specific user photos + ScopeCertificationApprove = "certification.approve" + ScopeCertificationList = "certification.list" + ScopeCertificationView = "certification.view" + + // Website administration + // - Forum: ability to manage available forums + // - Scopes: ability to manage admin groups & scopes + ScopeForumAdmin = "admin.forum.manage" + ScopeAdminScopeAdmin = "admin.scope.manage" + + // User account admin + // - Impersonate: ability to log in as a user account + // - Ban: ability to ban/unban users + // - Delete: ability to delete user accounts + ScopeUserImpersonate = "admin.user.impersonate" + ScopeUserBan = "admin.user.ban" + ScopeUserPromote = "admin.user.promote" + ScopeUserDelete = "admin.user.delete" + + // Special scope to mark an admin automagically in the Inner Circle + ScopeIsInnerCircle = "admin.override.inner-circle" +) + +// Number of expected scopes for unit test and validation. +const QuantityAdminScopes = 14 + +// The specially named Superusers group. +const AdminGroupSuperusers = "Superusers" + +// ListAdminScopes returns the listing of all available admin scopes. +func ListAdminScopes() []string { + return []string{ + ScopeChatModerator, + ScopeForumModerator, + ScopePhotoModerator, + ScopeCircleModerator, + ScopeCertificationApprove, + ScopeCertificationList, + ScopeCertificationView, + ScopeForumAdmin, + ScopeAdminScopeAdmin, + ScopeUserImpersonate, + ScopeUserBan, + ScopeUserDelete, + ScopeUserPromote, + ScopeIsInnerCircle, + } +} diff --git a/pkg/config/admin_scopes_test.go b/pkg/config/admin_scopes_test.go new file mode 100644 index 0000000..1d582f1 --- /dev/null +++ b/pkg/config/admin_scopes_test.go @@ -0,0 +1,21 @@ +package config_test + +import ( + "testing" + + "code.nonshy.com/nonshy/website/pkg/config" +) + +// TestAdminScopesCount validates that all named admin scopes are +// returned by the scope list function. +func TestAdminScopesCount(t *testing.T) { + var scopes = config.ListAdminScopes() + if len(scopes) != config.QuantityAdminScopes { + t.Errorf( + "The list of scopes returned by ListAdminScopes doesn't match the expected count. "+ + "Expected %d, got %d", + config.QuantityAdminScopes, + len(scopes), + ) + } +} diff --git a/pkg/controller/admin/scopes.go b/pkg/controller/admin/scopes.go new file mode 100644 index 0000000..8ee88f8 --- /dev/null +++ b/pkg/controller/admin/scopes.go @@ -0,0 +1,195 @@ +package admin + +import ( + "net/http" + "strconv" + "strings" + + "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" +) + +// Scopes controller (/admin/scopes) +func Scopes() http.HandlerFunc { + tmpl := templates.Must("admin/scopes.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query parameters. + var ( + intent = r.FormValue("intent") + editGroupIDStr = r.FormValue("id") + ) + + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get your current user: %s", err) + } + + _ = currentUser + + // List all of the admin users, groups & scopes. + adminUsers, err := models.ListAdminUsers() + if err != nil { + session.FlashError(w, r, "ListAdminUsers: %s", err) + } + adminGroups, err := models.ListAdminGroups() + if err != nil { + session.FlashError(w, r, "ListAdminGroups: %s", err) + } + + // Does the Superusers group exist yet? + var needSuperuserInit = true + for _, group := range adminGroups { + if group.Name == config.AdminGroupSuperusers { + needSuperuserInit = false + break + } + } + + // Validate that the Edit Group ID exists. + var ( + editGroupID int + editGroup = &models.AdminGroup{} + ) + if editGroupIDStr != "" { + groupID, err := strconv.Atoi(editGroupIDStr) + if err != nil { + session.FlashError(w, r, "Group ID is not a valid integer") + templates.Redirect(w, r.URL.Path) + return + } + + var found bool + for _, group := range adminGroups { + if group.ID == uint64(groupID) { + editGroup = group + found = true + break + } + } + + if !found && groupID > 0 { + session.FlashError(w, r, "Group ID not found.") + templates.Redirect(w, r.URL.Path) + return + } + + editGroupID = groupID + } + + // POST event handlers. + if r.Method == http.MethodPost { + // Scope check. + if !currentUser.HasAdminScope(config.ScopeAdminScopeAdmin) && intent != "init-superusers" { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeAdminScopeAdmin) + templates.Redirect(w, r.URL.Path) + return + } + + switch intent { + case "init-superusers": + // Initialize the Superusers group, if it does not already exist. + if !needSuperuserInit { + session.FlashError(w, r, "Could not initialize the Superusers group: it already exists.") + break + } + + group, err := models.CreateAdminGroup(config.AdminGroupSuperusers, []string{"*"}) + if err != nil { + session.FlashError(w, r, "Couldn't create Superusers group: %s", err) + break + } + + // Add the current admin user to it. + if err := group.AddUser(currentUser); err != nil { + session.FlashError(w, r, "Couldn't add you to the Superusers group: %s", err) + break + } + + session.Flash(w, r, "The Superusers group has been initialized and you placed in it.") + case "save": + // Create or Save an AdminGroup. + var ( + groupName = r.PostFormValue("name") + groupScopes = strings.Split(r.PostFormValue("scopes"), "\n") + groupUsers = r.PostForm["username"] + ) + + if editGroupID == 0 { + // New group: easiest option. + group, err := models.CreateAdminGroup(groupName, groupScopes) + if err != nil { + session.FlashError(w, r, "Couldn't create new group: %s", err) + break + } + + // Apply the user list to it. + added, removed, err := group.ReplaceUsers(groupUsers) + if err != nil { + session.FlashError(w, r, "Couldn't save users in this group: %s", err) + break + } + + session.Flash(w, r, "Saved admin group with %d scopes.", len(groupScopes)) + if len(added) > 0 { + session.Flash(w, r, "Added %s to the group.", strings.Join(added, ", ")) + } + if len(removed) > 0 { + session.Flash(w, r, "Removed %s from the group.", strings.Join(removed, ", ")) + } + } else { + // Updating the existing group. + if err := editGroup.ReplaceScopes(groupScopes); err != nil { + session.FlashError(w, r, "Couldn't replace scopes: %s", err) + break + } + + added, removed, err := editGroup.ReplaceUsers(groupUsers) + if err != nil { + session.FlashError(w, r, "Couldn't save users in this group: %s", err) + break + } + + session.Flash(w, r, "Saved admin group with %d scopes.", len(groupScopes)) + if len(added) > 0 { + session.Flash(w, r, "Added %s to the group.", strings.Join(added, ", ")) + } + if len(removed) > 0 { + session.Flash(w, r, "Removed %s from the group.", strings.Join(removed, ", ")) + } + } + case "delete": + if editGroupID == 0 { + session.FlashError(w, r, "Can't delete group: no group ID") + } else { + err := editGroup.Delete() + if err != nil { + session.FlashError(w, r, "Couldn't delete group: %s", err) + } else { + session.Flash(w, r, "Group deleted!") + } + } + default: + session.FlashError(w, r, "Unsupported intent: %s", intent) + } + + templates.Redirect(w, r.URL.Path) + return + } + + var vars = map[string]interface{}{ + "Intent": intent, + "AdminUsers": adminUsers, + "AdminGroups": adminGroups, + "AdminScopes": config.ListAdminScopes(), + "NeedSuperuserInit": needSuperuserInit, + "EditGroupID": editGroupID, + "EditGroup": editGroup, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/admin/user_actions.go b/pkg/controller/admin/user_actions.go index d8166df..3b2b1bf 100644 --- a/pkg/controller/admin/user_actions.go +++ b/pkg/controller/admin/user_actions.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models/deletion" "code.nonshy.com/nonshy/website/pkg/session" @@ -47,6 +48,13 @@ func UserActions() http.HandlerFunc { switch intent { case "impersonate": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeUserImpersonate) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserImpersonate) + templates.Redirect(w, "/admin") + return + } + if confirm { if err := session.ImpersonateUser(w, r, user, currentUser, reason); err != nil { session.FlashError(w, r, "Failed to impersonate user: %s", err) @@ -57,6 +65,13 @@ func UserActions() http.HandlerFunc { } } case "ban": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeUserBan) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserBan) + templates.Redirect(w, "/admin") + return + } + if confirm { status := r.PostFormValue("status") if status == "active" { @@ -71,6 +86,13 @@ func UserActions() http.HandlerFunc { return } case "promote": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeUserPromote) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserPromote) + templates.Redirect(w, "/admin") + return + } + if confirm { action := r.PostFormValue("action") user.IsAdmin = action == "promote" @@ -80,6 +102,13 @@ func UserActions() http.HandlerFunc { return } case "delete": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeUserDelete) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserDelete) + templates.Redirect(w, "/admin") + return + } + if confirm { if err := deletion.DeleteUser(user); err != nil { session.FlashError(w, r, "Failed when deleting the user: %s", err) diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go index 6182a0d..e97ed93 100644 --- a/pkg/controller/chat/chat.go +++ b/pkg/controller/chat/chat.go @@ -82,7 +82,7 @@ func Landing() http.HandlerFunc { // Create the JWT claims. claims := Claims{ - IsAdmin: currentUser.IsAdmin, + IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator), Avatar: avatar, ProfileURL: "/u/" + currentUser.Username, Nickname: currentUser.NameOrUsername(), diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index 3329051..6d529b7 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -115,7 +115,7 @@ func NewPost() http.HandlerFunc { comment = found // Verify that it is indeed OUR comment. - if currentUser.ID != comment.UserID && !currentUser.IsAdmin { + if currentUser.ID != comment.UserID && !currentUser.HasAdminScope(config.ScopeForumModerator) { templates.ForbiddenPage(w, r) return } diff --git a/pkg/controller/photo/certification.go b/pkg/controller/photo/certification.go index 7d2eeee..a61bc17 100644 --- a/pkg/controller/photo/certification.go +++ b/pkg/controller/photo/certification.go @@ -180,8 +180,33 @@ func AdminCertification() http.HandlerFunc { session.FlashError(w, r, "Couldn't get CurrentUser: %s", err) } + // Scope check based on view. + switch view { + case "pending": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeCertificationApprove) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationApprove) + templates.Redirect(w, "/admin") + return + } + case "approved", "rejected": + // Scope check. + if !currentUser.HasAdminScope(config.ScopeCertificationList) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationList) + templates.Redirect(w, "/admin") + return + } + } + // Short circuit the GET view for username/email search (exact match) if username := r.FormValue("username"); username != "" { + // Scope check. + if !currentUser.HasAdminScope(config.ScopeCertificationView) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationView) + templates.Redirect(w, "/admin") + return + } + user, err := models.FindUser(username) if err != nil { session.FlashError(w, r, "Username or email '%s' not found.", username) @@ -210,6 +235,13 @@ func AdminCertification() http.HandlerFunc { // Making a verdict? if r.Method == http.MethodPost { + // Scope check. + if !currentUser.HasAdminScope(config.ScopeCertificationApprove) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationApprove) + templates.Redirect(w, r.URL.Path) + return + } + var ( comment = r.PostFormValue("comment") verdict = r.PostFormValue("verdict") diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index 9758233..a369bfe 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -57,6 +57,11 @@ func SiteGallery() http.HandlerFunc { // Is the current viewer shy? var isShy = currentUser.IsShy() + // Admin scope warning. + if adminView && !currentUser.HasAdminScope(config.ScopePhotoModerator) { + session.FlashError(w, r, "Missing admin scope: %s", config.ScopePhotoModerator) + } + // Get the page of photos. pager := &models.Pagination{ Page: 1, diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 50f3087..97744ed 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -95,7 +95,7 @@ func UserPhotos() http.HandlerFunc { // What set of visibilities to query? visibility := []models.PhotoVisibility{models.PhotoPublic} - if isOwnPhotos || isGrantee || currentUser.IsAdmin { + if isOwnPhotos || isGrantee || currentUser.HasAdminScope(config.ScopePhotoModerator) { visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate) } else if models.AreFriends(user.ID, currentUser.ID) { visibility = append(visibility, models.PhotoFriends) diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go index bc20902..db3fc35 100644 --- a/pkg/middleware/authentication.go +++ b/pkg/middleware/authentication.go @@ -60,7 +60,7 @@ func LoginRequired(handler http.Handler) http.Handler { } // AdminRequired middleware. -func AdminRequired(handler http.Handler) http.Handler { +func AdminRequired(scope string, handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // User must be logged in. @@ -77,12 +77,22 @@ func AdminRequired(handler http.Handler) http.Handler { // Admin required. if !currentUser.IsAdmin { - log.Error("AdminRequired: %s", err) errhandler := templates.MakeErrorPage("Admin Required", "You do not have permission for this page.", http.StatusForbidden) errhandler.ServeHTTP(w, r.WithContext(ctx)) return } + // Ensure the admin scope. + if scope != "" && !currentUser.HasAdminScope(scope) { + errhandler := templates.MakeErrorPage( + "Admin Scope Required", + "Missing required admin scope: "+scope, + http.StatusForbidden, + ) + errhandler.ServeHTTP(w, r.WithContext(ctx)) + return + } + handler.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/pkg/models/admin_scopes.go b/pkg/models/admin_scopes.go new file mode 100644 index 0000000..17c52c7 --- /dev/null +++ b/pkg/models/admin_scopes.go @@ -0,0 +1,290 @@ +package models + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "code.nonshy.com/nonshy/website/pkg/log" + "gorm.io/gorm" +) + +// AdminGroup table gives a name to a set of admin permission scopes. +type AdminGroup struct { + ID uint64 `gorm:"primaryKey"` + Name string `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time + Scopes []AdminScope `gorm:"foreignKey:GroupID"` + Users []*User `gorm:"many2many:admin_group_users;"` +} + +// AdminScope table maps admin group IDs to named scopes (which may contain wildcards). +type AdminScope struct { + ID uint64 `gorm:"primaryKey"` + GroupID uint64 `gorm:"index"` + Scope string +} + +// Preload related tables for the AdminGroup. +func (g *AdminGroup) Preload() *gorm.DB { + return DB.Preload("Scopes").Preload("Users") +} + +// HasAdminScope returns whether the user is an admin and has the requested scope. +func (u *User) HasAdminScope(scope string) bool { + if !u.IsAdmin { + return false + } + + for _, group := range u.AdminGroups { + for _, compare := range group.Scopes { + // Scope has a wildcard? + if strings.ContainsRune(compare.Scope, '*') { + re := regexp.MustCompile( + fmt.Sprintf(`^%s$`, strings.ReplaceAll(compare.Scope, "*", ".+?")), + ) + if res := re.FindStringSubmatch(scope); len(res) > 0 { + log.Debug("Regexp scope '%s' matched requested '%s'", compare.Scope, scope) + return true + } + } else if compare.Scope == scope { + return true + } + } + } + + return false +} + +// ListAdminGroups returns the list of admin group (names) the user belongs to. +func (u *User) ListAdminGroups() []string { + var names = []string{} + for _, group := range u.AdminGroups { + names = append(names, group.Name) + } + sort.Strings(names) + return names +} + +// ListAdminScopes returns the list of admin group (names) the user belongs to. +func (u *User) ListAdminScopes() []string { + var ( + names = []string{} + distinct = map[string]interface{}{} + ) + for _, group := range u.AdminGroups { + for _, scope := range group.Scopes { + if _, ok := distinct[scope.Scope]; ok { + continue + } + distinct[scope.Scope] = nil + names = append(names, scope.Scope) + } + } + sort.Strings(names) + return names +} + +// CreateAdminGroup inserts a new admin group. +func CreateAdminGroup(name string, scopes []string) (*AdminGroup, error) { + // Verify the name is unique. + if _, err := FindAdminGroup(name); err == nil { + return nil, fmt.Errorf("that group name already exists: %s", err) + } + + g := &AdminGroup{ + Name: name, + Scopes: []AdminScope{}, + } + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope != "" { + g.Scopes = append(g.Scopes, AdminScope{ + Scope: scope, + }) + } + } + + result := DB.Create(g) + return g, result.Error +} + +// FindAdminGroup by name. +func FindAdminGroup(name string) (*AdminGroup, error) { + if name == "" { + return nil, errors.New("username is required") + } + + g := &AdminGroup{} + result := g.Preload().Where("name = ?", name).Limit(1).First(g) + return g, result.Error +} + +// ListAdminGroups returns all admin groups. +func ListAdminGroups() ([]*AdminGroup, error) { + var groups = []*AdminGroup{} + + result := (&AdminGroup{}).Preload().Order("name asc").Find(&groups) + return groups, result.Error +} + +// ListAdminUsers returns all admin user accounts. +func ListAdminUsers() ([]*User, error) { + var users = []*User{} + + result := (&User{}).Preload().Where("is_admin is true").Order("id asc").Find(&users) + return users, result.Error +} + +// ScopesString returns the scopes as a newline separated string. +func (g *AdminGroup) ScopesString() string { + var scopes = []string{} + for _, scope := range g.Scopes { + scopes = append(scopes, scope.Scope) + } + return strings.Join(scopes, "\n") +} + +// ReplaceScopes sets new scopes for the admin group. +func (g *AdminGroup) ReplaceScopes(scopes []string) error { + // Delete the original scopes. + var replace = []AdminScope{} + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope != "" { + replace = append(replace, AdminScope{ + Scope: scope, + }) + } + } + + err := DB.Model(g).Association("Scopes").Replace(replace) + + // Cleanup orphaned scopes. + if result := DB.Where("group_id IS NULL").Delete(&AdminScope{}); result.Error != nil { + log.Error("AdminGroup.ReplaceScopes: cleanup orphaned scopes: %s", result.Error) + } + + return err +} + +// HasAdmin returns whether the given username is a member of the group. +func (g *AdminGroup) HasAdmin(username string) bool { + for _, user := range g.Users { + if user.Username == username { + return true + } + } + return false +} + +// ReplaceUsers easily adds or removes admin users from a group. +// +// Post the full list of usernames who should be in the group, and it will add or +// remove users as needed and return which users were changed. +func (g *AdminGroup) ReplaceUsers(usernames []string) (added, removed []string, err error) { + added = []string{} + removed = []string{} + + // Map who is currently in the group. + var currentUsernames = map[string]interface{}{} + var allUsernames = []string{} + for _, user := range g.Users { + currentUsernames[user.Username] = nil + allUsernames = append(allUsernames, user.Username) + } + + // Map the incoming username list. + var incomingUsernames = map[string]interface{}{} + + // Who needs added? + for _, username := range usernames { + incomingUsernames[username] = nil + allUsernames = append(allUsernames, username) + if _, ok := currentUsernames[username]; !ok { + added = append(added, username) + } + } + + // Who needs removed? + for username := range currentUsernames { + if _, ok := incomingUsernames[username]; !ok { + removed = append(removed, username) + } + } + + log.Info("AdminGroup(%s).ReplaceUsers: complete list %s (adding: %s) (removing: %s)", + g.Name, + usernames, + added, + removed, + ) + + // Select all affected users from the DB. + usermap, err := MapUsersByUsername(allUsernames) + if err != nil { + return + } + + // Do the needful. + for _, username := range added { + log.Info("AdminGroup(%s).ReplaceUsers: ADD user %s", g.Name, username) + if err := g.AddUser(usermap[username]); err != nil { + return added, removed, fmt.Errorf("adding user %s: %s", username, err) + } + } + for _, username := range removed { + log.Info("AdminGroup(%s).ReplaceUsers: REMOVE user %s", g.Name, username) + if err := g.RemoveUser(usermap[username]); err != nil { + return added, removed, fmt.Errorf("removing user %s: %s", username, err) + } + } + + return +} + +// AddUser puts the given user into the admin group. +func (g *AdminGroup) AddUser(user *User) error { + if user.AdminGroups == nil { + user.AdminGroups = []*AdminGroup{} + } + + // Already exists? + for _, group := range user.AdminGroups { + if group.ID == g.ID { + return nil + } + } + + // Insert them. + user.AdminGroups = append(user.AdminGroups, g) + return user.Save() +} + +// RemoveUser removes a user from the admin group. +func (g *AdminGroup) RemoveUser(user *User) error { + if user.AdminGroups == nil { + return errors.New("user had no admin groups") + } + + return DB.Model(user).Association("AdminGroups").Delete(g) +} + +// Delete the AdminGroup. +func (g *AdminGroup) Delete() error { + // Remove scopes. + if result := DB.Where("group_id = ?", g.ID).Delete(&AdminScope{}); result.Error != nil { + return fmt.Errorf("can't remove scopes for group ID %d: %s", g.ID, result.Error) + } + + // Remove users. + if _, _, err := g.ReplaceUsers([]string{}); err != nil { + return fmt.Errorf("can't remove users for group ID %d: %s", g.ID, err) + } + + return DB.Delete(g).Error +} diff --git a/pkg/models/friend.go b/pkg/models/friend.go index 6722858..1fba397 100644 --- a/pkg/models/friend.go +++ b/pkg/models/friend.go @@ -149,7 +149,7 @@ func FriendIDsInCircle(userId uint64) []uint64 { ).Scan(&userIDs) if err.Error != nil { - log.Error("SQL error collecting circle FriendIDs for %d: %s", userId, err) + log.Error("SQL error collecting circle FriendIDs for %d: %s", userId, err.Error) } return userIDs @@ -174,7 +174,7 @@ func FriendIDsInCircleAreExplicit(userId uint64) []uint64 { ).Scan(&userIDs) if err.Error != nil { - log.Error("SQL error collecting explicit FriendIDs for %d: %s", userId, err) + log.Error("SQL error collecting explicit FriendIDs for %d: %s", userId, err.Error) } return userIDs diff --git a/pkg/models/models.go b/pkg/models/models.go index 68647dd..318168e 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -26,4 +26,6 @@ func AutoMigrate() { DB.AutoMigrate(&CommentPhoto{}) DB.AutoMigrate(&Poll{}) DB.AutoMigrate(&PollVote{}) + DB.AutoMigrate(&AdminGroup{}) + DB.AutoMigrate(&AdminScope{}) } diff --git a/pkg/models/photo.go b/pkg/models/photo.go index f98bc61..da21754 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "gorm.io/gorm" ) @@ -302,7 +303,7 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot } // Admins see everything on the site (only an admin user can get an admin view). - adminView = user.IsAdmin && adminView + adminView = user.HasAdminScope(config.ScopePhotoModerator) && adminView // Include ourself in our friend IDs. friendIDs = append(friendIDs, userID) diff --git a/pkg/models/thread.go b/pkg/models/thread.go index 9d8c3c9..d31768f 100644 --- a/pkg/models/thread.go +++ b/pkg/models/thread.go @@ -269,7 +269,7 @@ func MapThreadStatistics(threads []*Thread) ThreadStatsMap { ).Group("table_id").Scan(&groups) if err != nil { - log.Error("MapThreadStatistics: SQL error: %s", err) + log.Error("MapThreadStatistics: SQL error: %s", err.Error) } // Map the results in. diff --git a/pkg/models/user.go b/pkg/models/user.go index e986f19..aad98c5 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -36,7 +36,8 @@ type User struct { // Relational tables. ProfileField []ProfileField ProfilePhotoID *uint64 - ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"` + ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"` + AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;"` // Current user's relationship to this user -- not stored in DB. UserRelationship UserRelationship `gorm:"-"` @@ -62,7 +63,7 @@ var UserVisibilityOptions = []UserVisibility{ // Preload related tables for the user (classmethod). func (u *User) Preload() *gorm.DB { - return DB.Preload("ProfileField").Preload("ProfilePhoto") + return DB.Preload("ProfileField").Preload("ProfilePhoto").Preload("AdminGroups.Scopes") } // UserStatus options. @@ -338,6 +339,47 @@ func (um UserMap) Get(id uint64) *User { return nil } +// MapUsersByUsername looks up a set of users in bulk and returns a UsernameMap suitable for templates. +// +// It is like MapUsers but by username instead of ID. +func MapUsersByUsername(usernames []string) (UsernameMap, error) { + var ( + usermap = UsernameMap{} + set = map[string]interface{}{} + distinct = []string{} + ) + + // Uniqueify users. + for _, uid := range usernames { + if _, ok := set[uid]; ok { + continue + } + set[uid] = nil + distinct = append(distinct, uid) + } + + var ( + users = []*User{} + result = (&User{}).Preload().Where("username IN ?", distinct).Find(&users) + ) + + if result.Error == nil { + for _, row := range users { + usermap[row.Username] = row + } + } + + // Assert we got the expected count. + if len(usermap) != len(distinct) { + return usermap, fmt.Errorf("didn't get all expected users (expected %d, got %d)", len(distinct), len(usermap)) + } + + return usermap, result.Error +} + +// UsernameMap helps map a set of users to look up by ID. +type UsernameMap map[string]*User + // NameOrUsername returns the name (if not null or empty) or the username. func (u *User) NameOrUsername() string { if u.Name != nil && len(*u.Name) > 0 { diff --git a/pkg/models/user_inner_circle.go b/pkg/models/user_inner_circle.go index 9c17318..f23e7df 100644 --- a/pkg/models/user_inner_circle.go +++ b/pkg/models/user_inner_circle.go @@ -3,6 +3,7 @@ package models import ( "errors" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" ) @@ -10,7 +11,7 @@ import ( // IsInnerCircle returns whether the user is in the inner circle (including if the user is an admin, who is always in the inner circle). func (u *User) IsInnerCircle() bool { - return u.InnerCircle || u.IsAdmin + return u.InnerCircle || u.HasAdminScope(config.ScopeIsInnerCircle) } // AddToInnerCircle adds a user to the circle, sending them a notification in the process. @@ -57,7 +58,7 @@ func RemoveFromInnerCircle(u *User) error { "user_id = ? AND visibility = ?", u.ID, PhotoInnerCircle, ).Update("visibility", PhotoFriends); err != nil { - log.Error("RemoveFromInnerCircle: couldn't update photo visibility: %s", err) + log.Error("RemoveFromInnerCircle: couldn't update photo visibility: %s", err.Error) } // Revoke any historic notification about the circle. diff --git a/pkg/router/router.go b/pkg/router/router.go index c56952b..347448e 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -80,13 +80,14 @@ func New() http.Handler { mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote())) // Admin endpoints. - mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) - mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification())) - mux.Handle("/admin/feedback", middleware.AdminRequired(admin.Feedback())) - mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions())) - mux.Handle("/forum/admin", middleware.AdminRequired(forum.Manage())) - mux.Handle("/forum/admin/edit", middleware.AdminRequired(forum.AddEdit())) - mux.Handle("/inner-circle/remove", middleware.AdminRequired(account.RemoveCircle())) + mux.Handle("/admin", middleware.AdminRequired("", admin.Dashboard())) + mux.Handle("/admin/scopes", middleware.AdminRequired("", admin.Scopes())) + mux.Handle("/admin/photo/certification", middleware.AdminRequired("", photo.AdminCertification())) + mux.Handle("/admin/feedback", middleware.AdminRequired("", admin.Feedback())) + mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions())) + mux.Handle("/forum/admin", middleware.AdminRequired(config.ScopeForumAdmin, forum.Manage())) + mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit())) + mux.Handle("/inner-circle/remove", middleware.AdminRequired(config.ScopeCircleModerator, account.RemoveCircle())) // JSON API endpoints. mux.HandleFunc("/v1/version", api.Version()) diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index ed98b48..822929e 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -142,6 +142,12 @@ Gallery: Admin View +
  • + + + Admin Permissions Management + +
  • @@ -149,4 +155,4 @@ -{{end}} \ No newline at end of file +{{end}} diff --git a/web/templates/admin/scopes.html b/web/templates/admin/scopes.html new file mode 100644 index 0000000..c4d2fae --- /dev/null +++ b/web/templates/admin/scopes.html @@ -0,0 +1,239 @@ +{{define "title"}}Admin - Scopes & Permissions{{end}} +{{define "content"}} +{{$Root := .}} +
    +
    +
    +
    +

    + Scopes & Permissions +

    +
    +
    +
    + + {{if or (eq .Intent "new") (eq .Intent "edit")}} + +
    +
    +

    + {{if .EditGroupID}}Edit{{else}}New{{end}} Admin Group +

    +
    + {{InputCSRF}} + + +
    + + +
    + +
    + + +

    + Enter scopes one per line. Wildcards (*) may be used, as in social.moderator.* +

    +
    + +
    + + {{range .AdminUsers}} +
    + +
    + {{end}} +

    + Select the admin user(s) who should be a part of this group. +

    +
    + +
    + + + {{if .EditGroupID}} + + {{end}} + + + Cancel + +
    +
    +
    + +
    +
    +
    +

    + Admin Scopes +

    +
    +
    +

    + The complete listing of available admin scopes on the website + are as follows: +

    + +
      + {{range .AdminScopes}} +
    • {{.}}
    • + {{end}} +
    +
    +
    +
    +
    + {{else}} +
    + +

    + This page allows you to manage the nuanced permissions for admin accounts on {{PrettyTitle}}. + Permissions are managed by placing admins into groups, and in turn, assigning one or more + scopes to each group. +

    + + + {{if .NeedSuperuserInit}} +
    +

    + The Superusers group was not found! Click the button below + to initialize the Superusers group and add yourself to it automatically. +

    + +

    +

    + {{InputCSRF}} + +
    +

    +
    + {{end}} + +
    + +

    Admin Users

    + +

    + Found {{len .AdminUsers}} admin user account{{Pluralize (len .AdminUsers)}}. +

    + + + + + + + + + + + + + {{range .AdminUsers}} + + + + + + + + {{end}} + +
    IDUsernameEmailAdmin GroupsScopes
    {{.ID}}{{.Username}}{{.Email}} +
      + {{range .ListAdminGroups}} +
    • {{.}}
    • + {{end}} +
    +
    +
      + {{range .ListAdminScopes}} +
    • {{.}}
    • + {{end}} +
    +
    + +
    + +

    Admin Groups

    + +

    + Found {{len .AdminGroups}} admin group{{Pluralize (len .AdminUsers)}}. + Create new group. +

    + + + + + + + + + + + + + {{range .AdminGroups}} + + + + + + + + {{end}} + +
    IDNameScopesUsersEdit
    {{.ID}}{{.Name}} +
      + {{range .Scopes}} +
    • {{.Scope}}
    • + {{end}} +
    +
    + + + Edit +
    + +
    + {{end}} + +
    +{{end}} diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index 737647f..25c1cfe 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -258,7 +258,7 @@ - {{if .CurrentUser.IsAdmin}} + {{if .CurrentUser.HasAdminScope "social.moderator.photo"}}