diff --git a/config/groups.go b/config/groups.go index 83ed4c3..8ef79a2 100644 --- a/config/groups.go +++ b/config/groups.go @@ -9,6 +9,7 @@ type GroupConfig struct { AllowAutoAcceptGroupFollowers bool AllowRemoteGroupFollowers bool DefaultGroupMemberRole int + DefaultDirectoryOptIn bool } var EnableGroupsFlag = cli.BoolFlag{ @@ -39,12 +40,20 @@ var DefaultGroupMemberRoleFlag = cli.IntFlag{ Value: 1, } +var DefaultDirectoryOptInFlag = cli.BoolFlag{ + Name: "default-directory-opt-in", + Usage: "The default directory opt-in value for groups.", + EnvVars: []string{"DEFAULT_GROUP_DIRECTORY_OPT_IN"}, + Value: true, +} + func NewGroupConfig(cliCtx *cli.Context) (GroupConfig, error) { cfg := GroupConfig{ EnableGroups: cliCtx.Bool("enable-groups"), AllowAutoAcceptGroupFollowers: cliCtx.Bool("allow-auto-accept-group-followers"), AllowRemoteGroupFollowers: cliCtx.Bool("allow-remote-group-followers"), DefaultGroupMemberRole: cliCtx.Int("default-group-member-role"), + DefaultDirectoryOptIn: cliCtx.Bool("default-directory-opt-in"), } return cfg, nil } diff --git a/errors/errors_generated.go b/errors/errors_generated.go index 2fdcb89..6e56593 100644 --- a/errors/errors_generated.go +++ b/errors/errors_generated.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// This file was generated by herr at 2020-04-03 22:46:50.892042017 -0400 EDT m=+0.007694263 +// This file was generated by herr at 2020-04-05 12:40:16.825087707 -0400 EDT m=+0.009599340 package errors import ( diff --git a/errors/errors_generated_test.go b/errors/errors_generated_test.go index 85e2ef3..d302fac 100644 --- a/errors/errors_generated_test.go +++ b/errors/errors_generated_test.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// This file was generated by herr at 2020-04-03 22:46:50.924488758 -0400 EDT m=+0.040140977 +// This file was generated by herr at 2020-04-05 12:40:16.85622214 -0400 EDT m=+0.040733718 package errors import ( diff --git a/migrations/20200405121249_issue-67-group-directory.down.sql b/migrations/20200405121249_issue-67-group-directory.down.sql new file mode 100644 index 0000000..194f009 --- /dev/null +++ b/migrations/20200405121249_issue-67-group-directory.down.sql @@ -0,0 +1 @@ +alter table groups drop column directory_opt_in; diff --git a/migrations/20200405121249_issue-67-group-directory.up.sql b/migrations/20200405121249_issue-67-group-directory.up.sql new file mode 100644 index 0000000..a8af30f --- /dev/null +++ b/migrations/20200405121249_issue-67-group-directory.up.sql @@ -0,0 +1,2 @@ +alter table groups + add directory_opt_in bool default true not null; diff --git a/storage/actor.go b/storage/actor.go index 453ff23..518d8f6 100644 --- a/storage/actor.go +++ b/storage/actor.go @@ -97,15 +97,6 @@ var ActorSubjectsFields = []string{ "updated_at", } -var ActorKeysFields = []string{ - "id", - "actor_id", - "key_id", - "pem", - "created_at", - "updated_at", -} - func actorsSelectQuery(join string, where []string) string { var query strings.Builder query.WriteString("SELECT ") @@ -382,4 +373,4 @@ func (s pgStorage) UpdateActorPayload(ctx context.Context, actorRowID uuid.UUID, now := s.now() _, err := s.db.ExecContext(ctx, "UPDATE actors SET payload = $3, updated_at = $2 WHERE id = $1", actorRowID, now, payload) return errors.WrapActorUpdateFailedError(err) -} \ No newline at end of file +} diff --git a/storage/groups.go b/storage/groups.go index 1982dc8..976f0d1 100644 --- a/storage/groups.go +++ b/storage/groups.go @@ -11,6 +11,7 @@ import ( "github.com/gofrs/uuid" + "github.com/ngerakines/tavern/common" "github.com/ngerakines/tavern/errors" ) @@ -39,12 +40,15 @@ type GroupStorage interface { RecordGroupMemberAll(ctx context.Context, rowID uuid.UUID, createdAt, updatedAt time.Time, groupActorRowID, memberActorRowID uuid.UUID, activity Payload, status RelationshipStatus, role GroupRole) (uuid.UUID, error) RemoveGroupInvitation(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) error RemoveGroupMember(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) error - UpdateGroupMemberRole(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, role GroupRole) error - UpdateGroupMemberStatus(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, status RelationshipStatus) error - UpdateGroupDefaultRole(ctx context.Context, groupActorRowID uuid.UUID, role GroupRole) error + UpdateGroupAbout(ctx context.Context, groupRowID uuid.UUID, about string) error UpdateGroupAcceptFollowers(ctx context.Context, groupRowID uuid.UUID, acceptFollowers bool) error UpdateGroupAllowRemote(ctx context.Context, groupRowID uuid.UUID, allowRemote bool) error - UpdateGroupAbout(ctx context.Context, groupRowID uuid.UUID, about string) error + UpdateGroupDefaultRole(ctx context.Context, groupActorRowID uuid.UUID, role GroupRole) error + UpdateGroupDirectoryOptIn(ctx context.Context, groupRowID uuid.UUID, value bool) error + UpdateGroupMemberRole(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, role GroupRole) error + UpdateGroupMemberStatus(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, status RelationshipStatus) error + ListGroupsPaginated(ctx context.Context, limit, offset int) ([]*Actor, error) + CountGroupsMembers(ctx context.Context, groupActorRowIDs []uuid.UUID) ([]Count, error) } type Group struct { @@ -59,6 +63,7 @@ type Group struct { AcceptFollowers bool DefaultMemberRole GroupRole AllowRemote bool + DirectoryOptIn bool OwnerID uuid.UUID ActorID uuid.UUID } @@ -86,6 +91,7 @@ var groupFields = []string{ "accept_followers", "default_member_role", "allow_remote", + "directory_opt_in", "owner", "actor_id", } @@ -185,6 +191,7 @@ func (s pgStorage) getGroups(ctx context.Context, query string, args ...interfac &group.AcceptFollowers, &group.DefaultMemberRole, &group.AllowRemote, + &group.DirectoryOptIn, &group.OwnerID, &group.ActorID) if err != nil { @@ -218,6 +225,15 @@ func (s pgStorage) CountGroupMembers(ctx context.Context, groupActorRowID uuid.U return s.wrappedRowCount(errors.WrapGroupQueryFailedError, ctx, `SELECT COUNT(*) FROM group_members WHERE group_actor_id = $1`, groupActorRowID) } +func (s pgStorage) CountGroupsMembers(ctx context.Context, groupActorRowIDs []uuid.UUID) ([]Count, error) { + if len(groupActorRowIDs) == 0 { + return []Count{}, nil + } + placeholders := common.DollarForEach(len(groupActorRowIDs)) + query := fmt.Sprintf(`SELECT group_actor_id, COUNT(*) FROM group_members WHERE group_actor_id IN (%s) GROUP BY group_actor_id`, strings.Join(placeholders, ",")) + return s.keyedCount(errors.WrapObjectBoostQueryFailedError, ctx, query, common.UUIDsToInterfaces(groupActorRowIDs)...) +} + func (s pgStorage) GroupMemberActorIDs(ctx context.Context, groupActorRowID uuid.UUID) ([]string, error) { query := `SELECT a.actor_id FROM group_members gm INNER JOIN actors a ON a.id = gm.member_actor_id WHERE gm.group_actor_id = $1 ORDER BY a.actor_id ASC` rows, err := s.db.QueryContext(ctx, query, groupActorRowID) @@ -346,3 +362,14 @@ func (s pgStorage) UpdateGroupAbout(ctx context.Context, groupRowID uuid.UUID, a _, err := s.db.ExecContext(ctx, "UPDATE groups SET about = $3, updated_at = $2 WHERE id = $1", groupRowID, now, about) return errors.WrapGroupUpdateFailedError(err) } + +func (s pgStorage) UpdateGroupDirectoryOptIn(ctx context.Context, groupRowID uuid.UUID, value bool) error { + now := s.now() + _, err := s.db.ExecContext(ctx, "UPDATE groups SET directory_opt_in = $3, updated_at = $2 WHERE id = $1", groupRowID, now, value) + return errors.WrapGroupUpdateFailedError(err) +} + +func (s pgStorage) ListGroupsPaginated(ctx context.Context, limit, offset int) ([]*Actor, error) { + query := actorsSelectQuery("groups g ON a.id = g.actor_id", nil) + " ORDER BY g.name ASC LIMIT $1 OFFSET $2" + return s.getActors(ctx, query, limit, offset) +} diff --git a/storage/storage.go b/storage/storage.go index ed53d23..ef0aa17 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -252,3 +252,11 @@ func (s pgStorage) selectStrings(ew errors.ErrorWrapper, ctx context.Context, qu return results, nil } + +func CountMap(counts []Count) map[string]int { + results := make(map[string]int) + for _, c := range counts { + results[c.Key] = c.Count + } + return results +} \ No newline at end of file diff --git a/templates/group_directory.html b/templates/group_directory.html new file mode 100644 index 0000000..c66e32e --- /dev/null +++ b/templates/group_directory.html @@ -0,0 +1,32 @@ +{{define "head"}}{{end}} +{{define "footer_script"}}{{end}} +{{define "content"}} +
+
+

Group Directory

+
+
+
+
+ + + + + + + + + + {{ range $group := .groups }} + + + + + + {{ end }} + +
NameMembersCreated
{{ $group.Name }}{{ $group.MemberCount }}{{ date $group.CreatedAt }}
+
+
+ {{ template "paged" .paged }} +{{end}} \ No newline at end of file diff --git a/templates/group_profile.html b/templates/group_profile.html index ef80399..3e2a590 100644 --- a/templates/group_profile.html +++ b/templates/group_profile.html @@ -68,6 +68,23 @@ +
+
+

Public Directory

+
+ +
+ + +
+ +
+
+

Follower Starting Role

diff --git a/templates/groups.html b/templates/groups.html index bf29ad2..97e0fc6 100644 --- a/templates/groups.html +++ b/templates/groups.html @@ -9,6 +9,9 @@ fill the need for providing context, similar to how tags work, audiance, because it rebroadcasts content, and distribution, similar to traditional mailing lists or user groups.

+

+ Browse existing groups listed in the Group Directory. +

To send a message to a group, make sure the group's inbox is a recipient of the activity. The easy way to do this is to include the group's network identifier (like @tavern-updates@tavern.town) diff --git a/web/command.go b/web/command.go index df923d0..7ea198d 100644 --- a/web/command.go +++ b/web/command.go @@ -79,6 +79,7 @@ var Command = cli.Command{ &config.AllowAutoAcceptGroupFollowersFlag, &config.AllowRemoteFollowersFlag, &config.DefaultGroupMemberRoleFlag, + &config.DefaultDirectoryOptInFlag, }, Action: serverCommandAction, } @@ -400,6 +401,8 @@ func serverCommandAction(cliCtx *cli.Context) error { authenticated.GET("/utilities", h.utilities) authenticated.POST("/utilities/webfinger", h.utilitiesWebfinger) authenticated.POST("/utilities/crawl", h.utilitiesCrawl) + + authenticated.GET("/directory/groups", h.groupDirectory) } var group run.Group diff --git a/web/handler_directory.go b/web/handler_directory.go new file mode 100644 index 0000000..442a937 --- /dev/null +++ b/web/handler_directory.go @@ -0,0 +1,88 @@ +package web + +import ( + "net/http" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/gofrs/uuid" + + "github.com/ngerakines/tavern/storage" +) + +type groupDirectorySummary struct { + Name string + ActorID string + MemberCount int + CreatedAt time.Time +} + +func (h handler) groupDirectory(c *gin.Context) { + session := sessions.Default(c) + ctx := c.Request.Context() + trans, transOK := c.Get("trans") + if !transOK { + panic("trans not found in context") + } + data := gin.H{ + "flashes": getFlashes(session), + "Trans": trans, + } + + var total int + var groups []groupDirectorySummary + + page := intParam(c, "page", 1) + limit := 20 + + txErr := storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error { + var err error + total, err = tx.RowCount(ctx, `SELECT COUNT(*) FROM groups WHERE directory_opt_in = $1`, true) + if err != nil { + return err + } + + paginatedGroups, err := tx.ListGroupsPaginated(ctx, limit, (page-1)*limit) + if err != nil { + return err + } + + if len(paginatedGroups) > 0 { + groupActorRowIDs := make([]uuid.UUID, len(paginatedGroups)) + for i, pg := range paginatedGroups { + groupActorRowIDs[i] = pg.ID + } + + groupCounts, err := tx.CountGroupsMembers(ctx, groupActorRowIDs) + if err != nil { + return err + } + keyedGroupCounts := storage.CountMap(groupCounts) + + for _, pg := range paginatedGroups { + groups = append(groups, groupDirectorySummary{ + Name: pg.Name, + ActorID: pg.ActorID, + MemberCount: keyedGroupCounts[pg.ID.String()], + CreatedAt: pg.CreatedAt, + }) + } + } + + return nil + }) + if txErr != nil { + h.hardFail(c, txErr) + return + } + + data["groups"] = groups + + paged, err := createPaged(limit, page, total, h.url("group_directory")) + if err == nil { + data["paged"] = paged + } + + c.HTML(http.StatusOK, "group_directory", data) +} diff --git a/web/handler_group.go b/web/handler_group.go index 04ce9af..1e14054 100644 --- a/web/handler_group.go +++ b/web/handler_group.go @@ -132,6 +132,10 @@ func (h handler) dashboardGroupsCreate(c *gin.Context) { if err != nil { return err } + err = tx.UpdateGroupDirectoryOptIn(ctx, groupRowID, h.groupConfig.DefaultDirectoryOptIn) + if err != nil { + return err + } err = tx.RecordActorKey(ctx, actorRowID, groupKeyID, publicKey) if err != nil { diff --git a/web/handler_group_profile.go b/web/handler_group_profile.go index dd0f151..e4dc157 100644 --- a/web/handler_group_profile.go +++ b/web/handler_group_profile.go @@ -171,6 +171,15 @@ func (h handler) configureGroup(c *gin.Context) { h.flashErrorOrFail(c, h.url("group", group.Name), txErr) return } + case "update_directory": + txErr := storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error { + directoryOptIn, _ := strconv.ParseBool(c.PostForm("directory")) + return tx.UpdateGroupDirectoryOptIn(ctx, group.ID, directoryOptIn) + }) + if txErr != nil { + h.flashErrorOrFail(c, h.url("group", group.Name), txErr) + return + } } c.Redirect(http.StatusFound, h.url("group", group.Name)) diff --git a/web/view.go b/web/view.go index 66c93aa..0289706 100644 --- a/web/view.go +++ b/web/view.go @@ -140,6 +140,9 @@ func tmplUrlGen(siteBase string) func(parts ...interface{}) string { case "group_manage": return fmt.Sprintf("%s/groups/%s/manage", siteBase, parts[1]) + case "group_directory": + return fmt.Sprintf("%s/directory/groups", siteBase) + case "groups": return fmt.Sprintf("%s/manage/groups", siteBase) case "groups_create":