Implemented group profile and manage group features. Closes #68.

This commit is contained in:
Nick Gerakines 2020-04-05 12:08:53 -04:00
parent 04d1423670
commit e8315dc13b
No known key found for this signature in database
GPG Key ID: 33D43D854F96B2E4
10 changed files with 279 additions and 90 deletions

View File

@ -71,5 +71,8 @@ func (job *webfinger) work() error {
}
_, err = fed.GetOrFetchActor(context.Background(), job.storage, job.logger, job.httpClient, work)
return err
if err != nil {
job.logger.Warn("webfinger error", zap.Error(err), zap.String("location", work))
}
return nil
}

View File

@ -30,6 +30,7 @@ type ActorStorage interface {
ActorSubjects(ctx context.Context, actors []uuid.UUID) ([]ActorAlias, error)
ActorAliasSubjectExists(ctx context.Context, alias string) (bool, error)
FilterGroupsByActorID(ctx context.Context, actorIDs []string) ([]string, error)
UpdateActorPayload(ctx context.Context, actorRowID uuid.UUID, payload Payload) error
}
type Actor struct {
@ -376,3 +377,9 @@ func (s pgStorage) FilterGroupsByActorID(ctx context.Context, actorIDs []string)
query := fmt.Sprintf(`SELECT actor_id FROM actors WHERE payload->>'type' = 'Group' AND actor_id in (%s)`, strings.Join(common.DollarForEach(len(actorIDs)), ","))
return s.selectStrings(errors.WrapActorQueryFailedError, ctx, query, common.StringsToInterfaces(actorIDs)...)
}
func (s pgStorage) UpdateActorPayload(ctx context.Context, actorRowID uuid.UUID, payload Payload) error {
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

@ -44,6 +44,7 @@ type GroupStorage interface {
UpdateGroupDefaultRole(ctx context.Context, groupActorRowID uuid.UUID, role GroupRole) 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
}
type Group struct {
@ -325,17 +326,23 @@ func (s pgStorage) MinutesSinceGroupBoost(ctx context.Context, groupActorRowID,
func (s pgStorage) UpdateGroupDefaultRole(ctx context.Context, groupRowID uuid.UUID, role GroupRole) error {
now := s.now()
_, err := s.db.ExecContext(ctx, "UPDATE groups SET default_member_role = $3, updated_at = $2 WHERE id = $1", groupRowID, now, role)
return errors.WrapUserUpdateFailedError(err)
return errors.WrapGroupUpdateFailedError(err)
}
func (s pgStorage) UpdateGroupAcceptFollowers(ctx context.Context, groupRowID uuid.UUID, acceptFollowers bool) error {
now := s.now()
_, err := s.db.ExecContext(ctx, "UPDATE groups SET accept_followers = $3, updated_at = $2 WHERE id = $1", groupRowID, now, acceptFollowers)
return errors.WrapUserUpdateFailedError(err)
return errors.WrapGroupUpdateFailedError(err)
}
func (s pgStorage) UpdateGroupAllowRemote(ctx context.Context, groupRowID uuid.UUID, allowRemote bool) error {
now := s.now()
_, err := s.db.ExecContext(ctx, "UPDATE groups SET allow_remote = $3, updated_at = $2 WHERE id = $1", groupRowID, now, allowRemote)
return errors.WrapUserUpdateFailedError(err)
return errors.WrapGroupUpdateFailedError(err)
}
func (s pgStorage) UpdateGroupAbout(ctx context.Context, groupRowID uuid.UUID, about string) error {
now := s.now()
_, err := s.db.ExecContext(ctx, "UPDATE groups SET about = $3, updated_at = $2 WHERE id = $1", groupRowID, now, about)
return errors.WrapGroupUpdateFailedError(err)
}

View File

@ -7,7 +7,7 @@
<div class="col">
<h1>@{{ .group.Name }}@{{ .domain }}</h1>
{{ if .group.About }}
<p class="lead">{{ .group.About }}</p>
{{ .group.About | toHTML }}
{{ else }}
<p class="text-muted">This group does not have a description.</p>
{{ end }}
@ -24,8 +24,8 @@
<div class="row pt-3">
<div class="col">
<h3>Update Description</h3>
<form method="POST" action="{{ url "update_group" }}" id="update_about">
<input type="hidden" name="actor_id" value="{{ .group_actor.ActorID }}"/>
<form method="POST" action="{{ url "group" $group.Name }}" id="update_about">
<input type="hidden" name="action" value="update_about"/>
<div class="form-group">
<label for="updateGroupAbout">About</label>
<input type="text" class="form-control" id="updateGroupAbout" name="about" required>
@ -36,15 +36,58 @@
</div>
<div class="row pt-3">
<div class="col">
<h3>Invite</h3>
<form method="POST" action="{{ url "group_invite" }}" id="update_about">
<input type="hidden" name="group" value="{{ .group_actor.ActorID }}"/>
<div class="form-group">
<label for="groupInviteActor">Actor</label>
<input type="text" class="form-control" id="groupInviteActor" name="actor"
placeholder="@nick@tavern.town" required>
<h3>Auto-Accept</h3>
<form method="POST" action="{{ url "group" $group.Name }}" id="update_auto_accept">
<input type="hidden" name="action" value="update_auto_accept"/>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="updateGroupAutoAccept" name="auto_follow"
{{ if $group.AcceptFollowers }}
checked="checked"
{{ end }}
value="true">
<label class="form-check-label" for="updateGroupAutoAccept">Auto-Accept Followers</label>
</div>
<input type="submit" class="btn btn-dark" name="submit" value="Invite"/>
<input type="submit" class="btn btn-dark" name="submit" value="Update"/>
</form>
</div>
</div>
<div class="row pt-3">
<div class="col">
<h3>Allow Remote</h3>
<form method="POST" action="{{ url "group" $group.Name }}" id="update_allow_remote">
<input type="hidden" name="action" value="update_allow_remote"/>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="updateGroupAllowRemote" name="allow_remote"
{{ if $group.AllowRemote }}
checked="checked"
{{ end }}
value="true">
<label class="form-check-label" for="updateGroupAllowRemote">Allow Remote Followers</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>
<form method="POST" action="{{ url "group" $group.Name }}" id="update_default_role">
<input type="hidden" name="action" value="update_default_role"/>
<div class="form-group">
<label for="updateGroupDefaultRole">Role</label>
<select class="form-control" id="updateGroupDefaultRole" name="default_role">
<option value="0" {{ if eq 0 $group.DefaultMemberRole }} selected="selected"{{ end }}>
Viewer
</option>
<option value="1" {{ if eq 1 $group.DefaultMemberRole }} selected="selected"{{ end }}>
Contributor
</option>
<option value="2" {{ if eq 2 $group.DefaultMemberRole }} selected="selected"{{ end }}>
Manager
</option>
</select>
</div>
<input type="submit" class="btn btn-dark" name="submit" value="Update"/>
</form>
</div>
</div>
@ -62,7 +105,7 @@
{{ range $m := .members }}
<tr {{- if eq $m.ActorID $userActor.ActorID }} class="table-info" {{- end -}}>
<td>
{{ $m.ActorID }}
<a href="{{ $m.ActorID }}">{{ $m.ActorID }}</a>
</td>
<td>
{{ if not (eq $m.ActorID $userActor.ActorID) }}

View File

@ -61,7 +61,7 @@
{{ range .groups }}
<tr>
<td>
{{ .ActorID }}
<a href="{{ .ActorID }}">{{ .ActorID }}</a>
</td>
</tr>
{{ end }}

View File

@ -32,7 +32,9 @@
<tbody>
{{ range .following }}
<tr>
<td>{{ . }}</td>
<td>
<a href="{{ . }}">{{ . }}</a>
</td>
<td>
<form method="post" action="{{ url "network_unfollow" }}">
<input type="hidden" name="actor" value="{{ . }}"/>
@ -43,14 +45,20 @@
{{ end }}
{{ range .pending_following }}
<tr>
<td><span class="badge badge-danger">Pending</span> {{ . }}</td>
<td>
<span class="badge badge-danger">Pending</span>
<a href="{{ . }}">{{ . }}</a>
</td>
<td>
</td>
</tr>
{{ end }}
{{ range .groups }}
<tr>
<td><span class="badge badge-success">Group</span> {{ . }}</td>
<td>
<span class="badge badge-success">Group</span>
<a href="{{ . }}">{{ . }}</a>
</td>
<td>
<form method="post" action="{{ url "network_unfollow" }}">
<input type="hidden" name="actor" value="{{ . }}"/>
@ -64,7 +72,7 @@
<td>
<span class="badge badge-success">Group</span>
<span class="badge badge-danger">Pending</span>
{{ . }}
<a href="{{ . }}">{{ . }}</a>
</td>
<td class="d-flex">
<div>

View File

@ -350,6 +350,7 @@ func serverCommandAction(cliCtx *cli.Context) error {
groupActor := authenticated.Group("/groups")
{
groupActor.GET("/:name", h.groupActorInfo)
groupActor.POST("/:name", h.configureGroup)
groupActor.POST("/:name/inbox", h.groupActorInbox)
groupActor.GET("/:name/outbox", h.groupActorOutbox)
groupActor.GET("/:name/following", h.groupActorFollowing)

53
web/handler_auth.go Normal file
View File

@ -0,0 +1,53 @@
package web
import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/ngerakines/tavern/errors"
"github.com/ngerakines/tavern/storage"
)
func (h handler) loggedIn(c *gin.Context, requireUser bool) (gin.H, *storage.User, sessions.Session, bool) {
session := sessions.Default(c)
ctx := c.Request.Context()
trans, transOK := c.Get("trans")
if !transOK {
panic(errors.NewTranslatorNotFoundError(nil))
}
data := gin.H{
"flashes": getFlashes(session),
"Trans": trans,
}
user, err := h.storage.GetUserBySession(ctx, session)
if requireUser && err != nil && errors.Is(err, errors.UserSessionNotFoundError{}) {
h.flashErrorOrFail(c, h.url(), errors.NewAuthenticationRequiredError(err))
return nil, nil, nil, false
} else if err != nil && !errors.Is(err, errors.UserSessionNotFoundError{}) {
h.hardFail(c, err)
return nil, nil, nil, false
}
if user != nil {
data["user"] = user
data["authenticated"] = true
}
return data, user, session, true
}
func (h handler) loggedInAPI(c *gin.Context, requireUser bool) (*storage.User, sessions.Session, bool) {
session := sessions.Default(c)
ctx := c.Request.Context()
user, err := h.storage.GetUserBySession(ctx, session)
if requireUser && err != nil && errors.Is(err, errors.UserSessionNotFoundError{}) {
h.flashErrorOrFail(c, h.url(), errors.NewAuthenticationRequiredError(err))
return nil, nil, false
} else if err != nil && !errors.Is(err, errors.UserSessionNotFoundError{}) {
h.hardFail(c, err)
return nil, nil, false
}
return user, session, true
}

View File

@ -13,39 +13,9 @@ import (
"github.com/gofrs/uuid"
"go.uber.org/zap"
"github.com/ngerakines/tavern/errors"
"github.com/ngerakines/tavern/storage"
)
func (h handler) loggedIn(c *gin.Context, requireUser bool) (gin.H, *storage.User, sessions.Session, bool) {
session := sessions.Default(c)
ctx := c.Request.Context()
trans, transOK := c.Get("trans")
if !transOK {
panic(errors.NewTranslatorNotFoundError(nil))
}
data := gin.H{
"flashes": getFlashes(session),
"Trans": trans,
}
user, err := h.storage.GetUserBySession(ctx, session)
if requireUser && err != nil && errors.Is(err, errors.UserSessionNotFoundError{}) {
h.flashErrorOrFail(c, h.url(), errors.NewAuthenticationRequiredError(err))
return nil, nil, nil, false
} else if err != nil && !errors.Is(err, errors.UserSessionNotFoundError{}) {
h.hardFail(c, err)
return nil, nil, nil, false
}
if user != nil {
data["user"] = user
data["authenticated"] = true
}
return data, user, session, true
}
func (h handler) saveSession(c *gin.Context, session sessions.Session) bool {
if err := session.Save(); err != nil {
h.hardFail(c, err)
@ -442,7 +412,7 @@ func (h handler) viewFeed(c *gin.Context) {
"self_url": h.url("feed_recent"),
}
page := intParam(c, "page", 1)
limit := 10
limit := 20
h.displayObjectFeed(c, true, meta, func(user *storage.User) (i int, payloads []storage.Payload, err error) {
total, err := h.storage.CountObjectEventPayloadsInFeed(c.Request.Context(), user.ID)
if err != nil {

View File

@ -1,12 +1,18 @@
package web
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2"
"go.uber.org/zap"
"github.com/ngerakines/tavern/errors"
"github.com/ngerakines/tavern/storage"
)
func (h handler) viewGroup(c *gin.Context) {
@ -21,28 +27,84 @@ func (h handler) viewGroup(c *gin.Context) {
"Trans": trans,
}
authenticated := true
localUser, err := h.storage.GetUserBySession(ctx, session)
if err != nil {
if !errors.Is(err, errors.NewNotFoundError(nil)) {
h.internalServerErrorJSON(c, err)
return
txErr := storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error {
authenticated := true
localUser, err := tx.GetUserBySession(ctx, session)
if err != nil {
if !errors.Is(err, errors.NewNotFoundError(nil)) {
return err
}
authenticated = false
}
authenticated = false
name := c.Param("name")
group, err := tx.GetGroupByName(ctx, name)
if err != nil {
return err
}
groupActor, err := tx.GetActor(ctx, group.ActorID)
if err != nil {
return err
}
data["group"] = group
data["group_actor"] = groupActor
data["domain"] = h.domain
totalMembers, err := tx.CountGroupMembers(ctx, group.ID)
if err != nil {
return err
}
data["members_total"] = totalMembers
if !authenticated {
return nil
}
data["authenticated"] = true
data["user"] = localUser
userActor, err := tx.GetActor(ctx, localUser.ActorID)
if err != nil {
return err
}
data["user_actor"] = userActor
members, err := tx.GroupMemberActorsForGroupActorID(ctx, group.ActorID)
if err != nil {
return err
}
data["members"] = members
return nil
})
if txErr != nil {
h.hardFail(c, txErr)
return
}
data["authenticated"] = authenticated
data["user"] = localUser
c.HTML(http.StatusOK, "group_profile", data)
}
name := c.Param("name")
func (h handler) configureGroup(c *gin.Context) {
user, _, cont := h.loggedInAPI(c, true)
if !cont {
return
}
group, err := h.storage.GetGroupByName(ctx, name)
ctx := c.Request.Context()
group, err := h.storage.GetGroupByName(ctx, c.Param("name"))
if err != nil {
if errors.Is(err, errors.NewNotFoundError(nil)) {
h.notFoundJSON(c, err)
return
}
h.internalServerErrorJSON(c, err)
h.hardFail(c, err)
return
}
if group.OwnerID != user.ID {
h.hardFail(c, err)
return
}
@ -52,29 +114,64 @@ func (h handler) viewGroup(c *gin.Context) {
return
}
data["group"] = group
data["group_actor"] = groupActor
data["domain"] = h.domain
action := c.PostForm("action")
switch action {
case "update_about":
txErr := storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error {
unsafe := blackfriday.Run([]byte(c.PostForm("about")))
html := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
if !authenticated {
c.HTML(http.StatusOK, "group_profile", data)
return
err := tx.UpdateGroupAbout(ctx, group.ID, string(html))
if err != nil {
return err
}
groupPayload := groupActor.Payload
groupPayload["summary"] = string(html)
return tx.UpdateActorPayload(ctx, group.ActorID, groupPayload)
})
if txErr != nil {
h.flashErrorOrFail(c, h.url("group", group.Name), txErr)
return
}
case "update_auto_accept":
txErr := storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error {
autoFollow, _ := strconv.ParseBool(c.PostForm("auto_follow"))
return tx.UpdateGroupAcceptFollowers(ctx, group.ID, autoFollow)
})
if txErr != nil {
h.flashErrorOrFail(c, h.url("group", group.Name), txErr)
return
}
case "update_default_role":
h.logger.Debug("update_default_role", zap.String("value", c.PostForm("default_role")))
txErr := storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error {
defaultMemberRole, err := strconv.Atoi(c.PostForm("default_role"))
if err != nil {
return err
}
switch storage.GroupRole(defaultMemberRole) {
case storage.GroupViewer, storage.GroupMember, storage.GroupOwner:
default:
return fmt.Errorf("invalid group role: %d", defaultMemberRole)
}
return tx.UpdateGroupDefaultRole(ctx, group.ID, storage.GroupRole(defaultMemberRole))
})
if txErr != nil {
h.flashErrorOrFail(c, h.url("group", group.Name), txErr)
return
}
case "update_allow_remote":
txErr := storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error {
allowRemote, _ := strconv.ParseBool(c.PostForm("allow_remote"))
return tx.UpdateGroupAllowRemote(ctx, group.ID, allowRemote)
})
if txErr != nil {
h.flashErrorOrFail(c, h.url("group", group.Name), txErr)
return
}
}
userActor, err := h.storage.GetActor(ctx, localUser.ActorID)
if err != nil {
h.hardFail(c, err)
return
}
data["user_actor"] = userActor
members, err := h.storage.GroupMemberActorsForGroupActorID(ctx, group.ActorID)
if err != nil {
h.hardFail(c, err)
return
}
data["members"] = members
c.HTML(http.StatusOK, "group_profile", data)
c.Redirect(http.StatusFound, h.url("group", group.Name))
}