mirror of https://gitlab.com/ngerakines/tavern.git
1028 lines
27 KiB
Go
1028 lines
27 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"io/ioutil"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/buckket/go-blurhash"
|
|
"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/common"
|
|
"github.com/ngerakines/tavern/errors"
|
|
"github.com/ngerakines/tavern/fed"
|
|
"github.com/ngerakines/tavern/storage"
|
|
)
|
|
|
|
func (h handler) compose(c *gin.Context) {
|
|
data, user, session, cont := h.loggedIn(c, true)
|
|
if !cont {
|
|
return
|
|
}
|
|
|
|
recipients := make([]string, 0)
|
|
|
|
inReplyTo, err := url.QueryUnescape(c.Query("inReplyTo"))
|
|
if err == nil && len(inReplyTo) > 0 {
|
|
inReplyToPayload, err := h.storage.ObjectPayloadByObjectID(c.Request.Context(), inReplyTo)
|
|
if err != nil {
|
|
h.flashErrorOrFail(c, h.url("compose"), err)
|
|
return
|
|
}
|
|
|
|
thread := fed.ObjectThread(inReplyToPayload)
|
|
|
|
attributedTo, ok := storage.JSONString(inReplyToPayload, "attributedTo")
|
|
if !ok {
|
|
h.flashErrorOrFail(c, h.url("compose"), fmt.Errorf("invalid object: missing attributedTo"))
|
|
return
|
|
}
|
|
|
|
data["thread_context"] = thread
|
|
data["in_reply_to"] = inReplyTo
|
|
recipients = append(recipients, attributedTo)
|
|
}
|
|
|
|
if len(recipients) > 0 {
|
|
data["recipients"] = recipients
|
|
}
|
|
|
|
view := "compose"
|
|
if ok, err := strconv.ParseBool(c.Query("advanced")); ok && err == nil {
|
|
view = "compose_advanced"
|
|
}
|
|
|
|
data["followers_collection"] = storage.NewActorID(user.Name, h.domain).Followers()
|
|
|
|
if err := session.Save(); err != nil {
|
|
h.hardFail(c, errors.NewCannotSaveSessionError(err))
|
|
return
|
|
}
|
|
|
|
c.HTML(http.StatusOK, view, data)
|
|
}
|
|
|
|
func (h handler) createNote(c *gin.Context) {
|
|
session := sessions.Default(c)
|
|
ctx := c.Request.Context()
|
|
|
|
user, err := h.storage.GetUserBySession(ctx, session)
|
|
if err != nil {
|
|
if errors.Is(err, errors.NewUserNotFoundError(nil)) {
|
|
h.flashErrorOrFail(c, h.url(), errors.NewAuthenticationRequiredError(err))
|
|
return
|
|
}
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
|
|
userActor, err := h.storage.GetActor(ctx, user.ActorID)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
|
|
userActorID := storage.NewActorID(user.Name, h.domain)
|
|
|
|
advanced := false
|
|
if ok, err := strconv.ParseBool(c.Query("advanced")); ok && err == nil {
|
|
advanced = true
|
|
}
|
|
if !advanced {
|
|
if ok, err := strconv.ParseBool(c.PostForm("advanced")); ok && err == nil {
|
|
advanced = true
|
|
}
|
|
}
|
|
|
|
errorPage := h.url("compose")
|
|
if advanced {
|
|
errorPage = errorPage + "?advanced=true"
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
|
|
activityID := storage.NewV4()
|
|
|
|
to := make([]string, 0)
|
|
cc := make([]string, 0)
|
|
broadcastTo := true
|
|
broadcastCC := true
|
|
broadcastPeers := true
|
|
parseTags := true
|
|
parseMentions := true
|
|
// mediaType := "text/html"
|
|
language := "en"
|
|
summary := c.PostForm("summary")
|
|
threadContext := c.PostForm("thread_context")
|
|
inReplyTo := c.PostForm("inReplyTo")
|
|
content := c.PostForm("content")
|
|
|
|
sensitive, _ := strconv.ParseBool(c.PostForm("sensitive"))
|
|
|
|
// localObjectPrefix := fmt.Sprintf("https://%s/object/", h.domain)
|
|
|
|
if advanced {
|
|
broadcastTo, _ = strconv.ParseBool(c.PostForm("broadcastTo"))
|
|
broadcastCC, _ = strconv.ParseBool(c.PostForm("broadcastCc"))
|
|
broadcastPeers, _ = strconv.ParseBool(c.PostForm("broadcastPeers"))
|
|
parseTags, _ = strconv.ParseBool(c.PostForm("parseTags"))
|
|
parseMentions, _ = strconv.ParseBool(c.PostForm("parseMentions"))
|
|
// mediaType = c.PostForm("mediaType")
|
|
language = c.PostForm("language")
|
|
}
|
|
|
|
if len(threadContext) == 0 {
|
|
threadContext = fmt.Sprintf("https://%s/context/%s/%s", h.domain, now.Format("2006-01-02"), storage.NewV4())
|
|
}
|
|
|
|
if len(content) == 0 {
|
|
h.flashErrorOrFail(c, errorPage, fmt.Errorf("note is empty"))
|
|
return
|
|
}
|
|
|
|
activityURL := common.ActivityURL(h.domain, activityID)
|
|
|
|
publishedAt := now.Format("2006-01-02T15:04:05Z")
|
|
|
|
if !advanced {
|
|
// The default behavior is to make the post public, send it to followers, and add any recipients to cc.
|
|
to = append(to, "https://www.w3.org/ns/activitystreams#Public")
|
|
cc = append(cc, userActorID.Followers())
|
|
for _, recipient := range c.PostFormArray("recipient") {
|
|
cc = append(cc, recipient)
|
|
}
|
|
} else {
|
|
for _, dest := range strings.Fields(c.PostForm("to")) {
|
|
to = append(to, dest)
|
|
}
|
|
for _, dest := range strings.Fields(c.PostForm("cc")) {
|
|
cc = append(cc, dest)
|
|
}
|
|
}
|
|
|
|
mentionedActors := make(map[string]*storage.Actor)
|
|
|
|
var mentionedActorNames []string
|
|
|
|
if parseMentions {
|
|
mentionedActorNames = storage.FindMentionedActors(content)
|
|
}
|
|
if formMentions := c.PostForm("mentions"); len(formMentions) > 0 {
|
|
moreMentionedActorNames := storage.FindMentionedActors(c.PostForm("mentions"))
|
|
mentionedActorNames = append(mentionedActorNames, moreMentionedActorNames...)
|
|
}
|
|
|
|
for _, mentionedActor := range mentionedActorNames {
|
|
if _, ok := mentionedActors[mentionedActor]; ok {
|
|
continue
|
|
}
|
|
foundActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, h.httpClient, mentionedActor)
|
|
if err == nil {
|
|
mentionedActors[mentionedActor] = foundActor
|
|
to = append(to, foundActor.ActorID)
|
|
}
|
|
}
|
|
|
|
to = uniqueTo(to)
|
|
cc = uniqueCc(to, cc)
|
|
|
|
var mentionedTags []string
|
|
if parseTags {
|
|
mentionedTags = storage.FindMentionedTags(content)
|
|
}
|
|
|
|
isPublic := (len(to) > 0 && to[0] == "https://www.w3.org/ns/activitystreams#Public") || (len(cc) > 0 && cc[0] == "https://www.w3.org/ns/activitystreams#Public")
|
|
firstTags := make([]string, 0)
|
|
|
|
createNoteID := storage.NewV4()
|
|
createNote := storage.EmptyPayload()
|
|
createNote["@context"] = []interface{}{
|
|
"https://www.w3.org/ns/activitystreams",
|
|
map[string]string{
|
|
"sensitive": "as:sensitive",
|
|
},
|
|
}
|
|
createNote["actor"] = string(userActorID)
|
|
createNote["id"] = activityURL
|
|
createNote["published"] = publishedAt
|
|
createNote["type"] = "Create"
|
|
createNote["to"] = to
|
|
createNote["cc"] = cc
|
|
note := storage.EmptyPayload()
|
|
note["attributedTo"] = string(userActorID)
|
|
|
|
unsafe := blackfriday.Run([]byte(content))
|
|
html := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
|
|
note["content"] = string(html)
|
|
note["mediaType"] = "text/html"
|
|
|
|
note["sensitive"] = sensitive
|
|
|
|
source := storage.EmptyPayload()
|
|
source["content"] = content
|
|
source["mediaType"] = "markdown"
|
|
note["source"] = source
|
|
|
|
if len(language) > 0 {
|
|
note["contentMap"] = map[string]string{
|
|
language: string(html),
|
|
}
|
|
}
|
|
note["conversation"] = threadContext
|
|
note["context"] = threadContext
|
|
noteURL := common.ObjectURL(h.domain, createNoteID)
|
|
note["id"] = noteURL
|
|
note["published"] = publishedAt
|
|
note["summary"] = summary
|
|
note["to"] = to
|
|
if len(inReplyTo) > 0 {
|
|
note["inReplyTo"] = inReplyTo
|
|
}
|
|
note["cc"] = cc
|
|
note["type"] = "Note"
|
|
note["url"] = noteURL
|
|
|
|
replies := storage.EmptyPayload()
|
|
replies["id"] = common.ObjectRepliesURL(h.domain, createNoteID)
|
|
replies["id"] = "OrderedCollection"
|
|
replies["totalItems"] = 0
|
|
replies["published"] = publishedAt
|
|
replies["first"] = common.ObjectRepliesPageURL(h.domain, createNoteID, 1)
|
|
|
|
note["replies"] = replies
|
|
|
|
form, err := c.MultipartForm()
|
|
|
|
if err == nil {
|
|
files := form.File["upload[]"]
|
|
if len(files) > 0 {
|
|
parentTempDir := os.TempDir()
|
|
userUploadDir, err := ioutil.TempDir(parentTempDir, fmt.Sprintf("*-%s", user.ID))
|
|
if err != nil {
|
|
h.internalServerErrorJSON(c, err)
|
|
return
|
|
}
|
|
defer func() {
|
|
h.logger.Debug("deleting temp user upload directory", zap.String("directory", userUploadDir))
|
|
if rmErr := os.RemoveAll(userUploadDir); rmErr != nil {
|
|
h.logger.Warn("unable to delete temporary directory", zap.Error(rmErr))
|
|
}
|
|
}()
|
|
|
|
var images []storage.ImageAsset
|
|
|
|
for _, file := range files {
|
|
img, err := h.uploadHash(c.Request.Context(), file)
|
|
if err != nil {
|
|
h.internalServerErrorJSON(c, err)
|
|
return
|
|
}
|
|
images = append(images, img)
|
|
}
|
|
|
|
attachments := make([]map[string]interface{}, 0)
|
|
for _, img := range images {
|
|
attachments = append(attachments, map[string]interface{}{
|
|
"type": "Document",
|
|
"mediaType": img.GetContentType(),
|
|
"url": fmt.Sprintf("https://%s/asset/image/%s", h.domain, img.Checksum),
|
|
"name": "an image",
|
|
"blurhash": img.Blur,
|
|
"focalPoint": []int{0, 0},
|
|
})
|
|
}
|
|
if len(attachments) > 0 {
|
|
note["attachment"] = attachments
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(mentionedActors)+len(mentionedTags) > 0 {
|
|
tag := make([]map[string]interface{}, 0)
|
|
for name, actor := range mentionedActors {
|
|
tag = append(tag, map[string]interface{}{
|
|
"type": "Mention",
|
|
"href": actor.GetID(),
|
|
"name": name,
|
|
})
|
|
}
|
|
for _, hashtag := range mentionedTags {
|
|
clean := strings.TrimPrefix(hashtag, "#")
|
|
tag = append(tag, map[string]interface{}{
|
|
"type": "Hashtag",
|
|
"href": fmt.Sprintf("https://%s/tags/%s", h.domain, clean),
|
|
"name": hashtag,
|
|
})
|
|
if len(firstTags) < 10 {
|
|
firstTags = append(firstTags, clean)
|
|
}
|
|
}
|
|
note["tag"] = tag
|
|
}
|
|
|
|
createNote["object"] = note
|
|
payload := createNote.Bytes()
|
|
|
|
if broadcastTo || broadcastCC || broadcastPeers {
|
|
h.logger.Debug("broadcasting")
|
|
}
|
|
|
|
if ok, err := strconv.ParseBool(c.PostForm("previewJSON")); ok && err == nil {
|
|
c.JSON(http.StatusOK, createNote)
|
|
return
|
|
}
|
|
|
|
followerTotal, err := h.storage.RowCount(ctx, `SELECT COUNT(*) FROM network_graph WHERE user_id = $1`, user.ID)
|
|
if err != nil {
|
|
h.flashErrorOrFail(c, errorPage, err)
|
|
return
|
|
}
|
|
|
|
followers, err := h.storage.ListAcceptedFollowers(ctx, user.ID, followerTotal, 0)
|
|
if err != nil {
|
|
h.flashErrorOrFail(c, errorPage, err)
|
|
return
|
|
}
|
|
|
|
var toDestinations []string
|
|
var ccDestinations []string
|
|
for _, dest := range to {
|
|
if dest == "https://www.w3.org/ns/activitystreams#Public" {
|
|
continue
|
|
}
|
|
if dest == userActorID.Followers() {
|
|
toDestinations = append(toDestinations, followers...)
|
|
continue
|
|
}
|
|
toDestinations = append(toDestinations, dest)
|
|
}
|
|
|
|
for _, dest := range cc {
|
|
if dest == "https://www.w3.org/ns/activitystreams#Public" {
|
|
continue
|
|
}
|
|
if dest == userActorID.Followers() {
|
|
ccDestinations = append(ccDestinations, followers...)
|
|
continue
|
|
}
|
|
ccDestinations = append(ccDestinations, dest)
|
|
}
|
|
|
|
toDestinations = uniqueTo(toDestinations, string(userActorID))
|
|
ccDestinations = uniqueCc(toDestinations, ccDestinations, string(userActorID))
|
|
|
|
if ok, err := strconv.ParseBool(c.PostForm("previewDestinations")); ok && err == nil {
|
|
c.JSON(http.StatusOK, gin.H{"to": toDestinations, "cc": ccDestinations})
|
|
return
|
|
}
|
|
|
|
err = storage.TransactionalStorage(ctx, h.storage, func(storage storage.Storage) error {
|
|
activityObjectRowID, err := storage.RecordObject(ctx, note, noteURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
activityRowID, err := storage.RecordObjectEvent(ctx, activityURL, activityObjectRowID, createNote)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(inReplyTo) > 0 {
|
|
replyRowID, err := storage.ObjectRowIDForObjectID(ctx, inReplyTo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = storage.RecordObjectReply(ctx, activityObjectRowID, replyRowID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, err = storage.RecordUserObjectEvent(ctx, user.ID, activityRowID, activityObjectRowID, isPublic)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, tag := range firstTags {
|
|
_, err = storage.RecordObjectTag(ctx, activityObjectRowID, tag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
h.internalServerErrorJSON(c, err)
|
|
return
|
|
}
|
|
|
|
if !broadcastTo && broadcastCC && broadcastPeers {
|
|
c.Redirect(http.StatusFound, h.url("feed_mine"))
|
|
}
|
|
|
|
nc := fed.ActorClient{
|
|
HTTPClient: h.httpClient,
|
|
Logger: h.logger,
|
|
}
|
|
localActor := h.userActor(user, userActor)
|
|
|
|
if broadcastTo {
|
|
for _, dest := range toDestinations {
|
|
foundActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, h.httpClient, dest)
|
|
if err != nil {
|
|
h.logger.Error("unable to get or fetch actor", zap.Error(err), zap.String("actor", dest))
|
|
continue
|
|
}
|
|
if h.publisherClient != nil {
|
|
err = h.publisherClient.Send(context.Background(), foundActor.GetInbox(), userActor.GetKeyID(), user.PrivateKey, activityURL, string(payload))
|
|
if err != nil {
|
|
h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", activityURL), zap.Error(err))
|
|
}
|
|
} else {
|
|
err = nc.SendToInbox(ctx, localActor, foundActor, payload)
|
|
if err != nil {
|
|
h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", activityURL), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if broadcastCC {
|
|
for _, dest := range ccDestinations {
|
|
foundActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, h.httpClient, dest)
|
|
if err != nil {
|
|
h.logger.Error("unable to get or fetch actor", zap.Error(err), zap.String("actor", dest))
|
|
continue
|
|
}
|
|
if h.publisherClient != nil {
|
|
err = h.publisherClient.Send(context.Background(), foundActor.GetInbox(), userActor.GetKeyID(), user.PrivateKey, activityURL, string(payload))
|
|
if err != nil {
|
|
h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", activityURL), zap.Error(err))
|
|
}
|
|
} else {
|
|
err = nc.SendToInbox(ctx, localActor, foundActor, payload)
|
|
if err != nil {
|
|
h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", activityURL), zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.Redirect(http.StatusFound, h.url("feed_mine"))
|
|
}
|
|
|
|
func (h handler) announceNote(c *gin.Context) {
|
|
session := sessions.Default(c)
|
|
ctx := c.Request.Context()
|
|
|
|
user, err := h.storage.GetUserBySession(ctx, session)
|
|
if err != nil {
|
|
if errors.Is(err, errors.UserSessionNotFoundError{}) {
|
|
|
|
if err = appendFlashError(session, "Not Authenticated"); err != nil {
|
|
h.hardFail(c, errors.NewCannotSaveSessionError(err))
|
|
return
|
|
}
|
|
|
|
c.Redirect(http.StatusFound, h.url())
|
|
return
|
|
}
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
|
|
userActor, err := h.storage.GetActor(ctx, user.ActorID)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
|
|
objectID := c.PostForm("object")
|
|
if len(objectID) == 0 {
|
|
h.flashErrorOrFail(c, h.url("feed_recent"), fmt.Errorf("invalid object id"))
|
|
return
|
|
}
|
|
|
|
actor := storage.NewActorID(user.Name, h.domain)
|
|
now := time.Now().UTC()
|
|
publishedAt := now.Format("2006-01-02T15:04:05Z")
|
|
|
|
to := []string{
|
|
"https://www.w3.org/ns/activitystreams#Public",
|
|
}
|
|
cc := []string{
|
|
actor.Followers(),
|
|
}
|
|
|
|
announceID := fmt.Sprintf("https://%s/activity/%s", h.domain, storage.NewV4())
|
|
announce := storage.EmptyPayload()
|
|
announce["@context"] = "https://www.w3.org/ns/activitystreams"
|
|
announce["id"] = announceID
|
|
announce["type"] = "Announce"
|
|
announce["actor"] = actor
|
|
announce["published"] = publishedAt
|
|
announce["to"] = to
|
|
announce["cc"] = cc
|
|
announce["object"] = objectID
|
|
announcePayload := announce.Bytes()
|
|
|
|
var destinations []string
|
|
|
|
err = storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error {
|
|
objectRowID, err := tx.ObjectRowIDForObjectID(ctx, objectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
activityRowID, err := tx.RecordObjectEvent(ctx, announceID, objectRowID, announce)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tx.RecordUserObjectEvent(ctx, user.ID, activityRowID, objectRowID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.RecordObjectAnnouncement(ctx, userActor.ID, activityRowID, objectRowID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
followerTotal, err := tx.CountFollowers(ctx, user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
destinations, err = tx.ListAcceptedFollowers(ctx, user.ID, followerTotal, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
h.internalServerErrorJSON(c, err)
|
|
return
|
|
}
|
|
|
|
destinations = uniqueTo(destinations, userActor.ActorID)
|
|
|
|
nc := fed.ActorClient{
|
|
HTTPClient: h.httpClient,
|
|
Logger: h.logger,
|
|
}
|
|
localActor := h.userActor(user, userActor)
|
|
|
|
for _, dest := range destinations {
|
|
foundActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, h.httpClient, dest)
|
|
if err != nil {
|
|
h.logger.Error("unable to get or fetch actor", zap.Error(err), zap.String("actor", dest))
|
|
continue
|
|
}
|
|
|
|
if h.publisherClient != nil {
|
|
err = h.publisherClient.Send(context.Background(), foundActor.GetInbox(), userActor.GetKeyID(), user.PrivateKey, announceID, string(announcePayload))
|
|
if err != nil {
|
|
h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", announceID), zap.Error(err))
|
|
}
|
|
} else {
|
|
err = nc.SendToInbox(ctx, localActor, foundActor, announcePayload)
|
|
if err != nil {
|
|
h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", announceID))
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
c.Redirect(http.StatusFound, h.url("feed_mine"))
|
|
}
|
|
|
|
func (h handler) likeNote(c *gin.Context) {
|
|
session := sessions.Default(c)
|
|
ctx := c.Request.Context()
|
|
|
|
user, err := h.storage.GetUserBySession(ctx, session)
|
|
if err != nil {
|
|
if errors.Is(err, errors.UserSessionNotFoundError{}) {
|
|
|
|
if err = appendFlashError(session, "Not Authenticated"); err != nil {
|
|
h.hardFail(c, errors.NewCannotSaveSessionError(err))
|
|
return
|
|
}
|
|
|
|
c.Redirect(http.StatusFound, h.url())
|
|
return
|
|
}
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
|
|
userActor, err := h.storage.GetActor(ctx, user.ActorID)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
userActorFollowers, _ := storage.JSONString(userActor.Payload, "followers")
|
|
|
|
objectID := c.PostForm("object")
|
|
if len(objectID) == 0 {
|
|
h.flashErrorOrFail(c, h.url("feed_recent"), fmt.Errorf("invalid object id"))
|
|
return
|
|
}
|
|
|
|
objectPayload, err := h.storage.ObjectPayloadByObjectID(ctx, objectID)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
|
|
attributedTo, hasAttributedTo := storage.JSONString(objectPayload, "attributedTo")
|
|
if !hasAttributedTo {
|
|
h.hardFail(c, fmt.Errorf("object has no attributedTo attribute"), zap.String("object_id", objectID))
|
|
return
|
|
}
|
|
|
|
if userActor.ActorID == attributedTo {
|
|
h.hardFail(c, fmt.Errorf("you cannot like your own stuff"), zap.String("object_id", objectID))
|
|
return
|
|
}
|
|
|
|
sourceActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, h.httpClient, attributedTo)
|
|
if err != nil {
|
|
h.hardFail(c, err, zap.String("actor", attributedTo))
|
|
return
|
|
}
|
|
|
|
sourceActorInbox, hasSourceActorInbox := storage.JSONString(sourceActor.Payload, "inbox")
|
|
if !hasSourceActorInbox {
|
|
h.hardFail(c, fmt.Errorf("actor has no inbox attribute"), zap.String("actor", attributedTo))
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
publishedAt := now.Format("2006-01-02T15:04:05Z")
|
|
|
|
to := []string{
|
|
"https://www.w3.org/ns/activitystreams#Public",
|
|
attributedTo,
|
|
}
|
|
cc := []string{
|
|
userActorFollowers,
|
|
}
|
|
|
|
likeURL := common.ActivityURL(h.domain, storage.NewV4())
|
|
like := storage.EmptyPayload()
|
|
like["@context"] = "https://www.w3.org/ns/activitystreams"
|
|
like["id"] = likeURL
|
|
like["type"] = "Like"
|
|
like["actor"] = userActor.ActorID
|
|
like["published"] = publishedAt
|
|
like["to"] = to
|
|
like["cc"] = cc
|
|
like["object"] = objectID
|
|
likePayload := like.Bytes()
|
|
|
|
var destinations []string
|
|
|
|
err = storage.TransactionalStorage(ctx, h.storage, func(tx storage.Storage) error {
|
|
objectRowID, err := tx.ObjectRowIDForObjectID(ctx, objectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
activityRowID, err := tx.RecordObjectEvent(ctx, likeURL, objectRowID, like)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tx.RecordUserObjectEvent(ctx, user.ID, activityRowID, objectRowID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.RecordLike(ctx, userActor.ID, objectRowID, like)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
followerTotal, err := tx.CountFollowers(ctx, user.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
destinations, err = tx.ListAcceptedFollowers(ctx, user.ID, followerTotal, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
h.internalServerErrorJSON(c, err)
|
|
return
|
|
}
|
|
|
|
destinations = append(destinations, sourceActorInbox)
|
|
destinations = uniqueTo(destinations, userActor.ActorID)
|
|
|
|
nc := fed.ActorClient{
|
|
HTTPClient: h.httpClient,
|
|
Logger: h.logger,
|
|
}
|
|
localActor := h.userActor(user, userActor)
|
|
|
|
for _, dest := range destinations {
|
|
foundActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, h.httpClient, dest)
|
|
if err != nil {
|
|
h.logger.Error("unable to get or fetch actor", zap.Error(err), zap.String("actor", dest))
|
|
continue
|
|
}
|
|
|
|
if h.publisherClient != nil {
|
|
err = h.publisherClient.Send(context.Background(), foundActor.GetInbox(), userActor.GetKeyID(), user.PrivateKey, likeURL, string(likePayload))
|
|
if err != nil {
|
|
h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", likeURL), zap.Error(err))
|
|
}
|
|
} else {
|
|
err = nc.SendToInbox(ctx, localActor, foundActor, likePayload)
|
|
if err != nil {
|
|
h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", likeURL))
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
c.Redirect(http.StatusFound, h.url("feed_mine"))
|
|
}
|
|
|
|
func uniqueTo(input []string, exclude ...string) []string {
|
|
results := make([]string, 0)
|
|
|
|
if len(input) == 0 {
|
|
return results
|
|
}
|
|
|
|
hasPublic := false
|
|
imap := make(map[string]bool)
|
|
for _, i := range input {
|
|
if i == "https://www.w3.org/ns/activitystreams#Public" {
|
|
hasPublic = true
|
|
}
|
|
imap[i] = true
|
|
}
|
|
for _, ex := range exclude {
|
|
delete(imap, ex)
|
|
}
|
|
|
|
if hasPublic {
|
|
results = append(results, "https://www.w3.org/ns/activitystreams#Public")
|
|
}
|
|
for dest := range imap {
|
|
if dest == "https://www.w3.org/ns/activitystreams#Public" {
|
|
continue
|
|
}
|
|
results = append(results, dest)
|
|
}
|
|
return results
|
|
}
|
|
|
|
func uniqueCc(to []string, cc []string, exclude ...string) []string {
|
|
results := make([]string, 0)
|
|
|
|
if len(cc) == 0 {
|
|
return results
|
|
}
|
|
|
|
hasPublic := false
|
|
toMap := make(map[string]bool)
|
|
ccMap := make(map[string]bool)
|
|
for _, i := range to {
|
|
toMap[i] = true
|
|
}
|
|
for _, i := range cc {
|
|
if i == "https://www.w3.org/ns/activitystreams#Public" {
|
|
hasPublic = true
|
|
}
|
|
ccMap[i] = true
|
|
}
|
|
|
|
for _, ex := range exclude {
|
|
delete(ccMap, ex)
|
|
}
|
|
|
|
if hasPublic {
|
|
results = append(results, "https://www.w3.org/ns/activitystreams#Public")
|
|
}
|
|
for dest := range ccMap {
|
|
if dest == "https://www.w3.org/ns/activitystreams#Public" {
|
|
continue
|
|
}
|
|
if _, ok := toMap[dest]; ok {
|
|
continue
|
|
}
|
|
results = append(results, dest)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
func (h handler) uploadHash(ctx context.Context, file *multipart.FileHeader) (storage.ImageAsset, error) {
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
return storage.ImageAsset{}, err
|
|
}
|
|
defer src.Close()
|
|
|
|
tmpfile, err := ioutil.TempFile("", "image")
|
|
if err != nil {
|
|
return storage.ImageAsset{}, err
|
|
}
|
|
|
|
hasher := sha256.New()
|
|
|
|
w := io.MultiWriter(tmpfile, hasher)
|
|
|
|
lsr := io.LimitReader(src, 5000001) // 5 megs
|
|
|
|
written, err := io.Copy(w, lsr)
|
|
if err != nil {
|
|
return storage.ImageAsset{}, err
|
|
}
|
|
if written > 5000000 {
|
|
return storage.ImageAsset{}, fmt.Errorf("file size limit of 5 megabytes reached")
|
|
}
|
|
|
|
tmpFileName := tmpfile.Name()
|
|
|
|
checksum := hex.EncodeToString(hasher.Sum(nil))
|
|
|
|
err = tmpfile.Close()
|
|
|
|
bounds, ff, blurHash, err := imageBounds(tmpFileName)
|
|
if err != nil {
|
|
return storage.ImageAsset{}, err
|
|
}
|
|
|
|
contentType := storage.ContentTypeUnknown
|
|
switch ff {
|
|
case "png":
|
|
contentType = storage.ContentTypePNG
|
|
case "jpg", "jpeg":
|
|
contentType = storage.ContentTypeJPG
|
|
case "svg":
|
|
// This is put in here as a placeholder to do some basic parsing and detection of SVG images.
|
|
contentType = storage.ContentTypeSVG
|
|
}
|
|
|
|
fullLocation, err := h.assetStorage.Upload(context.Background(), checksum, tmpFileName)
|
|
if err != nil && !errors.Is(err, errors.NewAssetExistsError(nil)) {
|
|
return storage.ImageAsset{}, err
|
|
}
|
|
|
|
return h.storage.CreateImage(ctx, fullLocation, checksum, blurHash, int(written), contentType, bounds.Max.Y, bounds.Max.X, []string{})
|
|
}
|
|
|
|
func (h handler) deleteNote(c *gin.Context) {
|
|
_, user, _, cont := h.loggedIn(c, true)
|
|
if !cont {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
|
|
userActor, err := h.storage.GetActor(ctx, user.ActorID)
|
|
if err != nil {
|
|
h.hardFail(c, err)
|
|
return
|
|
}
|
|
|
|
objectID := c.PostForm("object_id")
|
|
|
|
now := time.Now().UTC()
|
|
deletedAt := now.Format("2006-01-02T15:04:05Z")
|
|
|
|
tombstone := storage.EmptyPayload()
|
|
tombstone["type"] = "Tombstone"
|
|
// TODO: Ensure that the object is, in fact, a note.
|
|
tombstone["formerType"] = "Note"
|
|
tombstone["id"] = objectID
|
|
tombstone["deleted"] = deletedAt
|
|
|
|
activityID := storage.NewV4()
|
|
activityURL := common.ActivityURL(h.domain, activityID)
|
|
|
|
deleteActivity := storage.EmptyPayload()
|
|
deleteActivity["@context"] = "https://www.w3.org/ns/activitystreams"
|
|
deleteActivity["id"] = activityURL
|
|
deleteActivity["type"] = "Delete"
|
|
deleteActivity["actor"] = common.ActorURL(h.domain, user.Name)
|
|
deleteActivity["to"] = []string{
|
|
"https://www.w3.org/ns/activitystreams#Public",
|
|
}
|
|
deleteActivity["object"] = tombstone
|
|
|
|
payload := deleteActivity.Bytes()
|
|
|
|
txErr := storage.TransactionalStorage(ctx, h.storage, func(storage storage.Storage) error {
|
|
objectRowID, err := storage.ObjectRowIDForObjectID(ctx, objectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO: Pass in the `Create` activity ID and verify the user, activity, and object all match.
|
|
count, err := storage.RowCount(ctx, `SELECT COUNT(*) FROM user_object_events WHERE user_id = $1 AND object_id = $2`, user.ID, objectRowID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if count == 0 {
|
|
return errors.NewNotFoundError(nil)
|
|
}
|
|
if count > 1 {
|
|
return fmt.Errorf("more than one activity found for object")
|
|
}
|
|
|
|
err = storage.UpdateObjectPayload(ctx, objectRowID, tombstone)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = storage.RecordObjectEvent(ctx, activityURL, objectRowID, deleteActivity)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if txErr != nil {
|
|
h.internalServerErrorJSON(c, txErr)
|
|
return
|
|
}
|
|
|
|
followerTotal, err := h.storage.CountFollowers(ctx, user.ID)
|
|
if err != nil {
|
|
h.internalServerErrorJSON(c, err)
|
|
return
|
|
}
|
|
|
|
destinations, err := h.storage.ListAcceptedFollowers(ctx, user.ID, followerTotal, 0)
|
|
if err != nil {
|
|
h.internalServerErrorJSON(c, err)
|
|
return
|
|
}
|
|
destinations = uniqueTo(destinations, common.ActivityURL(h.domain, user.Name))
|
|
|
|
nc := fed.ActorClient{
|
|
HTTPClient: h.httpClient,
|
|
Logger: h.logger,
|
|
}
|
|
localActor := h.userActor(user, userActor)
|
|
|
|
for _, dest := range destinations {
|
|
foundActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, h.httpClient, dest)
|
|
if err != nil {
|
|
h.logger.Error("unable to get or fetch actor", zap.Error(err), zap.String("actor", dest))
|
|
continue
|
|
}
|
|
err = nc.SendToInbox(ctx, localActor, foundActor, payload)
|
|
if err != nil {
|
|
h.logger.Error("failed sending to actor", zap.String("target", foundActor.GetID()), zap.String("activity", activityURL))
|
|
}
|
|
}
|
|
|
|
c.Redirect(http.StatusFound, h.url("feed_mine"))
|
|
}
|
|
|
|
func imageBounds(tempFileLocation string) (image.Rectangle, string, string, error) {
|
|
file, err := os.Open(tempFileLocation)
|
|
if err != nil {
|
|
return image.Rectangle{}, "", "", err
|
|
}
|
|
defer file.Close()
|
|
img, ff, err := image.Decode(file)
|
|
if err != nil {
|
|
return image.Rectangle{}, "", "", err
|
|
}
|
|
|
|
blurHash, err := blurhash.Encode(4, 3, &img)
|
|
if err != nil {
|
|
return image.Rectangle{}, "", "", err
|
|
}
|
|
|
|
return img.Bounds(), ff, blurHash, nil
|
|
}
|