From 72e1c67488b19b34d9d24be6a4ff868787cc115c Mon Sep 17 00:00:00 2001 From: mstar Date: Mon, 7 Jul 2025 12:40:37 +0200 Subject: [PATCH] Add global inbox, move announce --- activitypub/translators/announce.go | 23 ++ .../api/activitypub/activityAnnounce.go | 22 -- web/public/api/activitypub/inbox.go | 216 +++++++++++++++++- web/public/api/activitypub/server.go | 1 + 4 files changed, 229 insertions(+), 33 deletions(-) create mode 100644 activitypub/translators/announce.go diff --git a/activitypub/translators/announce.go b/activitypub/translators/announce.go new file mode 100644 index 0000000..07b28da --- /dev/null +++ b/activitypub/translators/announce.go @@ -0,0 +1,23 @@ +package translators + +import ( + "context" + "time" +) + +// Announce is boost + +type activityAnnounceOut struct { + Context any `json:"@context,omitempty"` + Id string + Type string // Always "Announce" + Actor string // The one doing the boost + Published time.Time + To []string + CC []string + Object string // Link to object being boosted +} + +func AnnounceFromStorage(ctx context.Context, id string) (*activityAnnounceOut, error) { + panic("not implemented") +} diff --git a/web/public/api/activitypub/activityAnnounce.go b/web/public/api/activitypub/activityAnnounce.go index b8b64da..7c30024 100644 --- a/web/public/api/activitypub/activityAnnounce.go +++ b/web/public/api/activitypub/activityAnnounce.go @@ -1,23 +1 @@ package activitypub - -import ( - "context" - "time" -) - -// Announce is boost - -type activityAnnounceOut struct { - Context any `json:"@context,omitempty"` - Id string - Type string // Always "Announce" - Actor string // The one doing the boost - Published time.Time - To []string - CC []string - Object string // Link to object being boosted -} - -func announceFromStorage(ctx context.Context, id string) (*activityAnnounceOut, error) { - panic("not implemented") -} diff --git a/web/public/api/activitypub/inbox.go b/web/public/api/activitypub/inbox.go index 0275984..e05597c 100644 --- a/web/public/api/activitypub/inbox.go +++ b/web/public/api/activitypub/inbox.go @@ -8,12 +8,14 @@ import ( "io" "net/http" "regexp" + "slices" "strconv" "strings" "time" webutils "git.mstar.dev/mstar/goutils/http" "git.mstar.dev/mstar/goutils/other" + "git.mstar.dev/mstar/goutils/sliceutils" "github.com/mitchellh/mapstructure" "github.com/rs/zerolog/hlog" "github.com/rs/zerolog/log" @@ -139,6 +141,108 @@ func userInbox(w http.ResponseWriter, r *http.Request) { } } +func globalInbox(w http.ResponseWriter, r *http.Request) { + log := hlog.FromRequest(r) + body, err := io.ReadAll(r.Body) + log.Trace(). + Err(err). + Bytes("body", body). + Any("headers", r.Header). + Msg("Global inbox message") + data := map[string]any{} + err = json.Unmarshal(body, &data) + if err != nil { + _ = webutils.ProblemDetails( + w, + http.StatusBadRequest, + "/errors/bad-request-data", + "Bad activity data", + other.IntoPointer("Body to inbox needs to be json"), + nil, + ) + return + } + if _, ok := data["@context"]; !ok { + _ = webutils.ProblemDetails( + w, + http.StatusBadRequest, + "/errors/bad-request-data", + "Bad activity data", + other.IntoPointer("Request data needs to contain context"), + nil, + ) + return + } + objectType, ok := data["type"].(string) + if !ok { + _ = webutils.ProblemDetails( + w, + http.StatusBadRequest, + "/errors/bad-request-data", + "Bad activity data", + other.IntoPointer(`Request data needs to contain a field "type" with a string value`), + nil, + ) + return + } + _, ok = data["id"].(string) + if !ok { + _ = webutils.ProblemDetails( + w, + http.StatusBadRequest, + "/errors/bad-request-data", + "Bad activity data", + other.IntoPointer(`Request data needs to contain a field "id" with a string value`), + nil, + ) + return + } + log.Debug().Str("object-type", objectType).Msg("Inbox message") + log.Trace().Bytes("body", body).Msg("Inbox raw message") + // TODO: Decide how to handle the handler failing for whatever reason + // Add object to unhandled message table and try again later? + // Discard it? And how would a handler return that it failed? + ok = true + switch objectType { + case "Like": + ok = handleLike(w, r, data) + case "Undo": + ok = handleUndo(w, r, data) + case "Accept": + ok = handleAccept(w, r, data) + case "Reject": + ok = handleReject(w, r, data) + case "Create": + ok = handleCreate(w, r, data) + default: + log.Warn(). + Str("object-type", objectType). + Msg("Unknown message type, storing for later processing") + err = dbgen.UnhandledMessage.Create(&models.UnhandledMessage{ + ForUserId: "", + GlobalInbox: true, + RawData: body, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to store unhandled message for later") + } + _ = webutils.ProblemDetailsStatusOnly(w, 500) + } + if !ok { + err = dbgen.UnhandledMessage.Create(&models.UnhandledMessage{ + RawData: body, + ForUserId: "", + GlobalInbox: true, + }) + if err != nil { + log.Error(). + Err(err). + Bytes("body", body). + Msg("Failed to store failed global inbound message for later processing") + } + } +} + func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) bool { log := hlog.FromRequest(r) activityId := object["id"].(string) @@ -780,13 +884,94 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any) dbNote.RepliesTo = sql.NullString{Valid: true, String: replyUrl} } } - feed, err := dbgen.Feed.Where(dbgen.Feed.OwnerId.Eq(targetUserId), dbgen.Feed.IsDefault.Is(true)). - First() - if err != nil { - log.Error().Err(err).Msg("Failed to get feed for targeted user inbox") - _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) - return false + totalNoteTargets := slices.Concat(objectNote.To, objectNote.CC) + // TODO: Find all feeds this note needs to be added to + // Depends on note targets (cc & to tags) + // Includes specific user IDs, nothing else -> DMs, only the personal feeds of those users + // Includes https://www.w3.org/ns/activitystreams#Public (& creator follower list) -> + // Public feed (unlisted has same combo, but iirc doesn't get sent out by server) + // Includes only creator follower list -> Follower only -> only feeds that explicitly follow creator + + u2u := dbgen.UserToUserRelation + targetFeeds := []models.Feed{} + if sliceutils.Contains(totalNoteTargets, "https://www.w3.org/ns/activitystreams#Public") { + // Public post, add to global and following feeds + dbNote.AccessLevel = models.NOTE_TARGET_PUBLIC + followerIds, err := u2u.GetLocalFollowerIdsOfId(actingUser.ID) + if err != nil { + log.Error(). + Err(err). + Str("follow-target", actingUser.ID). + Msg("Failed to get ids for followers") + return false + } + userFeeds, err := dbgen.Feed.Where(dbgen.Feed.OwnerId.In(followerIds...)).Find() + if err != nil { + log.Error(). + Err(err). + Str("follow-target", actingUser.ID). + Strs("follower-ids", followerIds). + Msg("Failed to get feeds for followers") + return false + } + globalFeed, err := dbgen.Feed.Where(dbgen.Feed.Name.Eq(models.GlobalFeedName)).First() + if err != nil { + log.Error(). + Err(err). + Msg("Failed to get global feed") + return false + } + targetFeeds = slices.Concat( + targetFeeds, + sliceutils.Map(userFeeds, func(t *models.Feed) models.Feed { return *t }), + ) + targetFeeds = append(targetFeeds, *globalFeed) + } else { + if sliceutils.ContainsFunc(totalNoteTargets, func(x string) bool { + return strings.HasPrefix(x, actingUser.ID) + }) { + // Contains an url starting with the author's id, so assume that it's for followers + dbNote.AccessLevel = models.NOTE_TARGET_FOLLOWERS + followerIds, err := u2u.GetLocalFollowerIdsOfId(actingUser.ID) + if err != nil { + log.Error(). + Err(err). + Str("follow-target", actingUser.ID). + Msg("Failed to get ids for followers") + return false + } + userFeeds, err := dbgen.Feed.Where(dbgen.Feed.OwnerId.In(followerIds...)).Find() + if err != nil { + log.Error(). + Err(err). + Str("follow-target", actingUser.ID). + Strs("follower-ids", followerIds). + Msg("Failed to get feeds for followers") + return false + } + targetFeeds = sliceutils.Map(userFeeds, func(t *models.Feed) models.Feed { return *t }) + } else { + // Neither followers collection url nor public marker, private message + dbNote.AccessLevel = models.NOTE_TARGET_DM + userFeeds, err := dbgen.Feed. + LeftJoin(dbgen.User, dbgen.User.ID.EqCol(dbgen.Feed.OwnerId)). + LeftJoin(dbgen.RemoteServer, dbgen.RemoteServer.ID.EqCol(dbgen.User.ServerId)). + LeftJoin(dbgen.UserRemoteLinks, dbgen.UserRemoteLinks.ID.EqCol(dbgen.User.RemoteInfoId)). + Where(dbgen.RemoteServer.IsSelf.Is(true)).Where( + dbgen.User.ID.In(totalNoteTargets...), + ).Or(dbgen.UserRemoteLinks.ApLink.In(totalNoteTargets...)).Find() + if err != nil { + log.Error(). + Err(err). + Str("follow-target", actingUser.ID). + Strs("targeted-ids", totalNoteTargets). + Msg("Failed to get feeds for directly messaged users") + return false + } + targetFeeds = sliceutils.Map(userFeeds, func(t *models.Feed) models.Feed { return *t }) + } } + tx := dbgen.Q.Begin() err = tx.Note.Create(&dbNote) if err != nil { @@ -801,6 +986,7 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any) ObjectId: dbNote.ID, ObjectType: uint32(models.ActivitystreamsActivityTargetNote), } + err = tx.Activity.Create(&createActivity) if err != nil { _ = tx.Rollback() @@ -808,17 +994,25 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return false } - err = tx.NoteToFeed.Create(&models.NoteToFeed{ - Reason: string(models.FeedAppearanceReasonFollowUser), - NoteId: dbNote.ID, - FeedId: uint64(feed.ID), + + feedRels := sliceutils.Map(targetFeeds, func(f models.Feed) *models.NoteToFeed { + return &models.NoteToFeed{ + Reason: string(models.FeedAppearanceReasonFollowUser), + NoteId: dbNote.ID, + FeedId: uint64(f.ID), + } }) + err = tx.NoteToFeed.Create(feedRels...) if err != nil { _ = tx.Rollback() - log.Error().Err(err).Any("note", dbNote).Msg("Failed to create note to feed relation in db") + log.Error(). + Err(err). + Any("note", dbNote). + Msg("Failed to create note to feed relations in db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return false } + err = tx.Commit() if err != nil { log.Error().Err(err).Any("note", dbNote).Msg("Failed to submit note creation in db") diff --git a/web/public/api/activitypub/server.go b/web/public/api/activitypub/server.go index fbc6335..b165399 100644 --- a/web/public/api/activitypub/server.go +++ b/web/public/api/activitypub/server.go @@ -20,5 +20,6 @@ func BuildActivitypubRouter() http.Handler { router.HandleFunc("GET /note/{id}", objectNote) router.HandleFunc("GET /note/{id}/reactions", noteReactions) router.HandleFunc("GET /emote/{id}", objectEmote) + router.HandleFunc("POST /inbox", globalInbox) return router }