mirror of https://gitlab.com/ngerakines/tavern.git
556 lines
16 KiB
Go
556 lines
16 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gofrs/uuid"
|
|
"github.com/microcosm-cc/bluemonday"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ngerakines/tavern/common"
|
|
"github.com/ngerakines/tavern/storage"
|
|
)
|
|
|
|
type feedObjectSource func(user *storage.User) (int, []storage.Payload, error)
|
|
|
|
func (h handler) displayObjectFeed(c *gin.Context, requireUser bool, vars map[string]interface{}, feedObjectSource feedObjectSource) {
|
|
data, user, session, cont := h.loggedIn(c, requireUser)
|
|
if !cont {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
|
|
for k, v := range vars {
|
|
data[k] = v
|
|
}
|
|
|
|
feedTemplate, hasFeedTemplate := storage.JSONString(vars, "feed_template")
|
|
if !hasFeedTemplate {
|
|
feedTemplate = "objects"
|
|
}
|
|
|
|
var err error
|
|
var localUserActor *storage.Actor
|
|
if user != nil {
|
|
localUserActor, err = h.storage.GetActor(c.Request.Context(), user.ActorID)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
page := intParam(c, "page", 1)
|
|
limit := 20
|
|
|
|
total, feedPayloads, err := feedObjectSource(user)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
|
|
if total == 0 {
|
|
if cont = h.saveSession(c, session); !cont {
|
|
return
|
|
}
|
|
c.HTML(http.StatusOK, feedTemplate, data)
|
|
return
|
|
}
|
|
|
|
data["total"] = total
|
|
data["objects"] = feedPayloads
|
|
|
|
foundPayloadIDs := common.NewUniqueStrings()
|
|
foundActorIDs := common.NewUniqueStrings()
|
|
feedIDs := common.NewUniqueStrings()
|
|
boosters := make(common.StringsMultiMap)
|
|
chains := make(common.UUIDsMultiMap)
|
|
|
|
for _, feedPayload := range feedPayloads {
|
|
feedIDs.Add(storage.CollectJSONDeepStrings(feedPayload, []string{"id"})...)
|
|
|
|
foundPayloadIDs.Add(storage.CollectJSONDeepStrings(feedPayload,
|
|
[]string{"id"},
|
|
[]string{"inReplyTo"},
|
|
[]string{"object"},
|
|
[]string{"object", "id"},
|
|
[]string{"object", "inReplyTo"})...)
|
|
|
|
foundActorIDs.Add(storage.CollectJSONDeepStrings(feedPayload, []string{"actor"}, []string{"object", "attributedTo"}, []string{"attributedTo"})...)
|
|
|
|
feedPayloadType, hasFeedPayloadType := storage.JSONString(feedPayload, "type")
|
|
if hasFeedPayloadType && feedPayloadType == "Announce" {
|
|
feedPayloadActor, hasFeedPayloadActor := storage.JSONString(feedPayload, "actor")
|
|
announcedObjectID, hasAnnouncedObjectID := storage.JSONString(feedPayload, "object")
|
|
if hasFeedPayloadActor && hasAnnouncedObjectID {
|
|
boosters.Add(announcedObjectID, feedPayloadActor)
|
|
}
|
|
}
|
|
}
|
|
|
|
data["found_payload_ids"] = foundPayloadIDs
|
|
data["found_actor_ids"] = foundActorIDs
|
|
|
|
actorNames := make(map[string]string)
|
|
actorIcons := make(map[string]string)
|
|
|
|
objectRowIDs, err := h.storage.ObjectRowIDsForObjectIDs(ctx, foundPayloadIDs.Values)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
data["collected_object_row_ids"] = objectRowIDs
|
|
|
|
collectedRowIDs := make([]uuid.UUID, 0, len(objectRowIDs))
|
|
for _, id := range objectRowIDs {
|
|
collectedRowIDs = append(collectedRowIDs, id)
|
|
}
|
|
refs, err := h.storage.ObjectPayloadByUUID(ctx, collectedRowIDs)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
h.logger.Debug("queried object refs", zap.Int("count", len(refs)))
|
|
|
|
hasParents := map[uuid.UUID]bool{}
|
|
|
|
for refRowID, _ := range refs {
|
|
hasParents[refRowID] = false
|
|
}
|
|
for _, feedPayload := range feedPayloads {
|
|
objectID := storage.FirstJSONDeepStrings(feedPayload, []string{"object"}, []string{"object", "id"})
|
|
if rowID, hasRowID := objectRowIDs[objectID]; hasRowID {
|
|
chains.Add(rowID)
|
|
}
|
|
}
|
|
for refRowID, ref := range refs {
|
|
inReplyTo, hasInReplyTo := storage.JSONString(ref, "inReplyTo")
|
|
if hasInReplyTo {
|
|
if rowID, hasRowID := objectRowIDs[inReplyTo]; hasRowID {
|
|
chains.Add(rowID, refRowID)
|
|
chains.Add(refRowID)
|
|
hasParents[refRowID] = true
|
|
}
|
|
} else {
|
|
chains.Add(refRowID)
|
|
}
|
|
}
|
|
|
|
boosts, err := h.storage.CountObjectBoostsByObjectIDs(ctx, collectedRowIDs)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
mappedBoosts := make(map[string]int)
|
|
for _, boost := range boosts {
|
|
mappedBoosts[boost.Key] = boost.Count
|
|
}
|
|
|
|
likes, err := h.storage.CountObjectLikesByObjectIDs(ctx, collectedRowIDs)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
mappedLikes := make(map[string]int)
|
|
for _, like := range likes {
|
|
mappedLikes[like.Key] = like.Count
|
|
}
|
|
|
|
mappedUserBoosts := make(map[string]int)
|
|
if localUserActor != nil {
|
|
userBoosts, err := h.storage.CountObjectBoostsByActorObjectIDs(ctx, localUserActor.ID, collectedRowIDs)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
|
|
for _, userBoost := range userBoosts {
|
|
mappedUserBoosts[userBoost.Key] = userBoost.Count
|
|
}
|
|
}
|
|
|
|
replies, err := h.storage.CountObjectRepliesByObjectIDs(ctx, collectedRowIDs)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
mappedReplies := make(map[string]int)
|
|
for _, reply := range replies {
|
|
mappedReplies[reply.Key] = reply.Count
|
|
}
|
|
|
|
for _, ref := range refs {
|
|
foundActorIDs.Add(storage.CollectJSONDeepStrings(ref, []string{"actor"}, []string{"attributedTo"}, []string{"object", "attributedTo"})...)
|
|
}
|
|
|
|
actors, err := h.storage.ActorsByActorID(ctx, foundActorIDs.Values)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
var actorRowIDs []uuid.UUID
|
|
for _, actor := range actors {
|
|
actorRowIDs = append(actorRowIDs, actor.ID)
|
|
}
|
|
actorSubjects, err := h.storage.ActorSubjects(ctx, actorRowIDs)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
allActors := h.gatherActors(actors, actorSubjects)
|
|
for actorID, actorSummary := range allActors {
|
|
actorNames[actorID] = actorSummary["at"]
|
|
actorIcons[actorID] = actorSummary["icon"]
|
|
}
|
|
|
|
mediaPrefix := fmt.Sprintf("https://%s/asset/image/", h.domain)
|
|
|
|
feed := make([]*viewObject, 0)
|
|
for objectRowID, _ := range chains {
|
|
|
|
hasParent, hasParentOk := hasParents[objectRowID]
|
|
if hasParentOk && hasParent {
|
|
continue
|
|
}
|
|
|
|
feedObjectPayload, ok := refs[objectRowID]
|
|
if !ok {
|
|
h.logger.Warn("feed object has no ref", zap.String("object_row_id", objectRowID.String()))
|
|
continue
|
|
}
|
|
|
|
element := createViewObject(objectRowID, feedObjectPayload, nil)
|
|
element.populate(0, chains.Flatten(), refs)
|
|
element.applyView(func(viewObjectRowID uuid.UUID, payload storage.Payload, viewContext *viewObjectContext) *viewObjectContext {
|
|
|
|
viewContext.ShortHash = hex.EncodeToString(viewObjectRowID.Bytes())
|
|
|
|
objectID, hasObjectID := storage.JSONString(payload, "id")
|
|
if !hasObjectID {
|
|
return &viewObjectContext{Error: "Object has no id."}
|
|
}
|
|
viewContext.ObjectID = objectID
|
|
|
|
objectType, hasObjectType := storage.JSONString(payload, "type")
|
|
if hasObjectType && objectType == "Tombstone" {
|
|
viewContext.Tombstone = true
|
|
viewContext.TombstoneFormerType, _ = storage.JSONString(payload, "formerType")
|
|
|
|
if deleted, hasDeleted := storage.JSONString(payload, "deleted"); hasDeleted {
|
|
var deletedAt time.Time
|
|
deletedAt, err := time.ParseInLocation("2006-01-02T15:04:05Z", deleted, time.UTC)
|
|
if err == nil {
|
|
viewContext.TombstoneHasDeletedAt = true
|
|
viewContext.TombstoneDeletedAt = deletedAt
|
|
}
|
|
|
|
}
|
|
|
|
return viewContext
|
|
}
|
|
|
|
objectBoosters, hasObjectBoosters := boosters[objectID]
|
|
if hasObjectBoosters {
|
|
viewContext.HasAnnouncers = true
|
|
viewContext.Announcers = make(map[string]string)
|
|
for _, booster := range objectBoosters.Values {
|
|
boosterName := booster
|
|
boosterLink := booster
|
|
|
|
if actorName, foundActorName := actorNames[booster]; foundActorName {
|
|
boosterName = actorName
|
|
}
|
|
|
|
viewContext.Announcers[boosterName] = boosterLink
|
|
}
|
|
}
|
|
|
|
summary := storage.FirstJSONDeepStrings(payload, []string{"summary"})
|
|
sensitive := storage.FirstJSONDeepBooleans(payload, []string{"sensitive"}, []string{"as:sensitive"})
|
|
if sensitive && len(summary) > 0 {
|
|
viewContext.Sensitive = true
|
|
viewContext.ContentWarning = summary
|
|
}
|
|
|
|
attributedTo, hasAttributedTo := storage.JSONString(payload, "attributedTo")
|
|
content, hasContent := storage.JSONString(payload, "content")
|
|
if !hasAttributedTo {
|
|
return &viewObjectContext{Error: "Object has no attribution."}
|
|
}
|
|
if !hasContent {
|
|
return &viewObjectContext{Error: "Object has no content."}
|
|
}
|
|
|
|
viewContext.Icon = fmt.Sprintf("https://%s/avatar/png/unknown/unknown?size=60", h.domain)
|
|
|
|
if actorIcon, hasActorIcon := actorIcons[attributedTo]; hasActorIcon {
|
|
viewContext.Icon = actorIcon
|
|
}
|
|
|
|
destinations := storage.ActivityDestinations(payload)
|
|
for _, destination := range destinations {
|
|
if destination == "https://www.w3.org/ns/activitystreams#Public" {
|
|
viewContext.Public = true
|
|
break
|
|
}
|
|
}
|
|
|
|
viewContext.Content = bluemonday.UGCPolicy().Sanitize(content)
|
|
|
|
viewContext.AttributedTo = attributedTo
|
|
viewContext.AttributedToLink = attributedTo
|
|
if actorName, foundActorName := actorNames[attributedTo]; foundActorName {
|
|
viewContext.AttributedTo = actorName
|
|
}
|
|
|
|
if objectURL, hasObjectURL := storage.JSONString(payload, "url"); hasObjectURL {
|
|
viewContext.LinkView = true
|
|
viewContext.LinkViewURL = objectURL
|
|
}
|
|
|
|
viewContext.LinkReply = true
|
|
if viewContext.LinkReply {
|
|
viewContext.LinkReplyURL = fmt.Sprintf("%s?inReplyTo=%s", h.url("compose"), url.QueryEscape(objectID))
|
|
}
|
|
if localUserActor != nil && attributedTo != localUserActor.ActorID {
|
|
viewContext.LinkAnnounce = true
|
|
viewContext.LinkLike = true
|
|
}
|
|
|
|
if localUserActor != nil && attributedTo == localUserActor.ActorID {
|
|
viewContext.LinkDelete = true
|
|
}
|
|
|
|
if published, hasPublished := storage.JSONString(payload, "published"); hasPublished {
|
|
var publishedAt time.Time
|
|
publishedAt, err := time.ParseInLocation("2006-01-02T15:04:05Z", published, time.UTC)
|
|
if err == nil {
|
|
viewContext.HasPublishedAt = true
|
|
viewContext.PublishedAt = publishedAt
|
|
}
|
|
}
|
|
|
|
if inReplyTo, hasInReplyTo := storage.JSONString(payload, "inReplyTo"); hasInReplyTo {
|
|
viewContext.HasParent = true
|
|
viewContext.ParentObjectLink = inReplyTo
|
|
|
|
viewContext.ParentAttributedToLink = inReplyTo
|
|
viewContext.ParentAttributedTo = "unknown"
|
|
|
|
if parentObjectRowID, hasParentObjectRowID := objectRowIDs[inReplyTo]; hasParentObjectRowID {
|
|
if parentRef, hasParentRef := refs[parentObjectRowID]; hasParentRef {
|
|
|
|
inReplyToAuthor, hasInReplyToAuthor := storage.JSONString(parentRef, "attributedTo")
|
|
if hasInReplyToAuthor {
|
|
viewContext.ParentAttributedToLink = inReplyToAuthor
|
|
viewContext.ParentAttributedTo = inReplyToAuthor
|
|
|
|
if actorName, foundActorName := actorNames[inReplyToAuthor]; foundActorName {
|
|
viewContext.ParentAttributedTo = actorName
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
objectTag, ok := storage.JSONMapList(payload, "tag")
|
|
if ok {
|
|
for _, tag := range objectTag {
|
|
tagType, hasType := storage.JSONString(tag, "type")
|
|
href, hasHref := storage.JSONString(tag, "href")
|
|
name, hasName := storage.JSONString(tag, "name")
|
|
if hasType && tagType == "Hashtag" && hasName {
|
|
viewContext.Tags = append(viewContext.Tags, name)
|
|
}
|
|
if hasType && tagType == "Mention" && hasHref {
|
|
viewContext.Mentions = append(viewContext.Mentions, href)
|
|
}
|
|
}
|
|
}
|
|
|
|
attachments, ok := storage.JSONMapList(payload, "attachment")
|
|
if ok {
|
|
for _, attachment := range attachments {
|
|
mediaURL, hasMediaURL := storage.JSONString(attachment, "url")
|
|
if hasMediaURL && strings.HasPrefix(mediaURL, "https://") {
|
|
blurHash, hasBlurHash := storage.JSONString(attachment, "blurhash")
|
|
mediaType, _ := storage.JSONString(attachment, "mediaType")
|
|
switch mediaType {
|
|
case "image/png", "image/jpeg":
|
|
if strings.HasPrefix(mediaURL, mediaPrefix) {
|
|
viewContext.Media = append(viewContext.Media, viewContentAttachment{
|
|
URL: mediaURL,
|
|
Thumbnail: fmt.Sprintf("https://%s/asset/blurhash/%s", h.domain, hex.EncodeToString([]byte(blurHash))),
|
|
})
|
|
} else if hasBlurHash {
|
|
viewContext.Media = append(viewContext.Media, viewContentAttachment{
|
|
URL: mediaURL,
|
|
Thumbnail: fmt.Sprintf("https://%s/asset/blurhash/%s", h.domain, hex.EncodeToString([]byte(blurHash))),
|
|
})
|
|
} else {
|
|
viewContext.Media = append(viewContext.Media, viewContentAttachment{
|
|
URL: mediaURL,
|
|
Thumbnail: mediaURL,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if count, hasCount := mappedUserBoosts[viewObjectRowID.String()]; hasCount && count > 0 {
|
|
viewContext.ViewerAnnounced = true
|
|
}
|
|
if count, hasCount := mappedBoosts[viewObjectRowID.String()]; hasCount {
|
|
viewContext.TotalAnnounces = count
|
|
}
|
|
if count, hasCount := mappedLikes[viewObjectRowID.String()]; hasCount {
|
|
viewContext.TotalLikes = count
|
|
}
|
|
if count, hasCount := mappedReplies[viewObjectRowID.String()]; hasCount {
|
|
viewContext.TotalReplies = count
|
|
}
|
|
|
|
return viewContext
|
|
})
|
|
feed = append(feed, element)
|
|
}
|
|
|
|
byPublished := func(v1, v2 *viewObject) bool {
|
|
return v1.ViewContext.PublishedAt.After(v2.ViewContext.PublishedAt)
|
|
}
|
|
vos := &viewObjectSorter{
|
|
viewObjects: feed,
|
|
by: byPublished,
|
|
}
|
|
sort.Sort(vos)
|
|
for _, element := range feed {
|
|
element.sort(byPublished)
|
|
}
|
|
|
|
data["feed"] = feed
|
|
|
|
selfURL, hasSelfURL := storage.JSONString(vars, "self_url")
|
|
|
|
if hasSelfURL {
|
|
paged, err := createPaged(limit, page, total, selfURL)
|
|
if err == nil {
|
|
data["paged"] = paged
|
|
} else {
|
|
h.logger.Error("error creating page", zap.Error(err), zap.String("url", selfURL))
|
|
}
|
|
}
|
|
|
|
if cont = h.saveSession(c, session); !cont {
|
|
return
|
|
}
|
|
c.HTML(http.StatusOK, feedTemplate, data)
|
|
}
|
|
|
|
func (h handler) viewFeed(c *gin.Context) {
|
|
meta := map[string]interface{}{
|
|
"feed_view": "recent",
|
|
"self_url": h.url("feed_recent"),
|
|
}
|
|
page := intParam(c, "page", 1)
|
|
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 {
|
|
return 0, nil, err
|
|
}
|
|
h.logger.Debug("counted objects in feed", zap.Int("total", total))
|
|
objects, err := h.storage.ListObjectEventPayloadsInFeed(c.Request.Context(), user.ID, limit, (page-1)*limit)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
h.logger.Debug("queried objects", zap.Int("count", len(objects)))
|
|
return total, objects, nil
|
|
})
|
|
}
|
|
|
|
func (h handler) viewMyFeed(c *gin.Context) {
|
|
meta := map[string]interface{}{
|
|
"feed_view": "mine",
|
|
"self_url": h.url("feed_mine"),
|
|
}
|
|
page := intParam(c, "page", 1)
|
|
limit := 20
|
|
h.displayObjectFeed(c, true, meta, func(user *storage.User) (i int, payloads []storage.Payload, err error) {
|
|
total, err := h.storage.CountObjectEventPayloadsInUserFeed(c.Request.Context(), user.ID)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
h.logger.Debug("counted objects in feed", zap.Int("total", total))
|
|
objects, err := h.storage.ListObjectEventPayloadsInUserFeed(c.Request.Context(), user.ID, limit, (page-1)*limit)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
h.logger.Debug("queried objects", zap.Int("count", len(objects)))
|
|
return total, objects, nil
|
|
})
|
|
}
|
|
|
|
func (h handler) viewLocalFeed(c *gin.Context) {
|
|
meta := map[string]interface{}{
|
|
"feed_view": "local",
|
|
"self_url": h.url("feed_local"),
|
|
}
|
|
page := intParam(c, "page", 1)
|
|
limit := 20
|
|
h.displayObjectFeed(c, true, meta, func(user *storage.User) (i int, payloads []storage.Payload, err error) {
|
|
total, err := h.storage.CountObjectPayloadsInLocalFeed(c.Request.Context())
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
h.logger.Debug("counted objects in feed", zap.Int("total", total))
|
|
objects, err := h.storage.ListObjectPayloadsInLocalFeed(c.Request.Context(), limit, (page-1)*limit)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
h.logger.Debug("queried objects", zap.Int("count", len(objects)))
|
|
return total, objects, nil
|
|
})
|
|
}
|
|
|
|
func (h handler) gatherActors(actors []*storage.Actor, actorSubjects []storage.ActorAlias) map[string]map[string]string {
|
|
results := make(map[string]map[string]string)
|
|
|
|
subjects := storage.CollectActorSubjectsActorToSubject(actorSubjects)
|
|
|
|
for _, actor := range actors {
|
|
summary := make(map[string]string)
|
|
|
|
subject, hasSubject := subjects[actor.ID]
|
|
if hasSubject {
|
|
trimmed := strings.TrimPrefix(subject, "acct:")
|
|
subjectParts := strings.Split(trimmed, "@")
|
|
if len(subjectParts) == 2 {
|
|
summary["at"] = trimmed
|
|
summary["icon"] = fmt.Sprintf("https://%s/avatar/png/%s/%s?size=60", h.domain, subjectParts[1], subjectParts[0])
|
|
results[actor.ActorID] = summary
|
|
continue
|
|
}
|
|
}
|
|
|
|
actorID := actor.ActorID
|
|
|
|
u, err := url.Parse(actorID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
domain := u.Hostname()
|
|
summary["at"] = fmt.Sprintf("%s@%s", actor.Name, domain)
|
|
summary["icon"] = fmt.Sprintf("https://%s/avatar/png/%s/%s?size=60", h.domain, domain, actor.Name)
|
|
results[actorID] = summary
|
|
}
|
|
|
|
return results
|
|
}
|