Implemented groups directory. Closes #67.

This commit is contained in:
Nick Gerakines 2020-04-05 12:50:31 -04:00
parent e8315dc13b
commit 0503e01375
No known key found for this signature in database
GPG Key ID: 33D43D854F96B2E4
16 changed files with 213 additions and 16 deletions

View File

@ -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
}

View File

@ -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 (

View File

@ -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 (

View File

@ -0,0 +1 @@
alter table groups drop column directory_opt_in;

View File

@ -0,0 +1,2 @@
alter table groups
add directory_opt_in bool default true not null;

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -0,0 +1,32 @@
{{define "head"}}{{end}}
{{define "footer_script"}}{{end}}
{{define "content"}}
<div class="row">
<div class="col">
<h1>Group Directory</h1>
</div>
</div>
<div class="row pt-3 ">
<div class="col">
<table class="table table-responsive-sm table-borderless table-hover table-sm">
<thead class="thead-dark">
<tr>
<th scope="col">Name</th>
<th scope="col">Members</th>
<th scope="col">Created</th>
</tr>
</thead>
<tbody>
{{ range $group := .groups }}
<tr>
<th scope="row">{{ $group.Name }}</th>
<td>{{ $group.MemberCount }}</td>
<td>{{ date $group.CreatedAt }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ template "paged" .paged }}
{{end}}

View File

@ -68,6 +68,23 @@
</form>
</div>
</div>
<div class="row pt-3">
<div class="col">
<h3>Public Directory</h3>
<form method="POST" action="{{ url "group" $group.Name }}" id="update_directory">
<input type="hidden" name="action" value="update_directory"/>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="updateGroupDirectoryOptIn" name="directory"
{{ if $group.DirectoryOptIn }}
checked="checked"
{{ end }}
value="true">
<label class="form-check-label" for="updateGroupDirectoryOptIn">Display in the groups directory</label>
</div>
<input type="submit" class="btn btn-dark" name="submit" value="Update"/>
</form>
</div>
</div>
<div class="row pt-3">
<div class="col">
<h3>Follower Starting Role</h3>

View File

@ -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.
</p>
<p>
Browse existing groups listed in the <a href="{{ url "group_directory" }}">Group Directory</a>.
</p>
<p>
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 <code>@tavern-updates@tavern.town</code>)

View File

@ -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

88
web/handler_directory.go Normal file
View File

@ -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)
}

View File

@ -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 {

View File

@ -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))

View File

@ -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":