commit 8403ab16d82c2829a3ee4c45b8147aae113b4d66 from: Oliver Lowe date: Thu Mar 21 02:56:41 2024 UTC apub, cmd/apsend: implement shared inbox delivery This helps servers hosting many actors, and it helps us when delivering to lots of people on the same server; we only need to make 1 request per server. commit - d32587396427e2e5dba703667d4bb7cd5bb4ad59 commit + 8403ab16d82c2829a3ee4c45b8147aae113b4d66 blob - b345580cbf145ad67518ffb16831a5f1f54ef4c3 blob + 4e0cb1a0f3ef7b5519d3283455aa3ca9a00fef29 --- apub.go +++ apub.go @@ -63,6 +63,7 @@ type Activity struct { Audience string `json:"audience,omitempty"` Href string `json:"href,omitempty"` Tag []Activity `json:"tag,omitempty"` + Endpoints Endpoints `json:"endpoints,omitempty"` // Contains a JSON-encoded Activity, or a URL as a JSON string // pointing to an Activity. Use Activity.Unwrap() to access // the enclosed, decoded value. @@ -138,6 +139,7 @@ type Actor struct { Inbox string `json:"inbox"` Outbox string `json:"outbox"` Followers string `json:"followers"` + Endpoints Endpoints `json:"endpoints,omitempty"` Published *time.Time `json:"published,omitempty"` PublicKey PublicKey `json:"publicKey"` } @@ -189,3 +191,32 @@ func (a *Actor) FollowersAddress() *mail.Address { } return addr } + +type Endpoints struct { + SharedInbox string `json:"sharedInbox,omitempty"` +} + +// Inboxes returns a deduplicated slice of inbox endpoints ActivityPub clients should send to. +// Shared inboxes, if present, are selected over an Actor's personal inbox. +// See W3C Recommendation ActivityPub Section 7.1.3 Shared Inbox Delivery. +func Inboxes(actors []Actor) []string { + var inboxes []string + for _, a := range actors { + box := a.Inbox + if a.Endpoints.SharedInbox != "" { + box = a.Endpoints.SharedInbox + } + var match bool + for i := range inboxes { + if inboxes[i] == box { + match = true + break + } + } + if match { + continue + } + inboxes = append(inboxes, box) + } + return inboxes +} blob - 4117b5de1e745b4944071681904bb67c6bdb4738 blob + ea725fd9a80255945d4fbc8e58f4bc86f1b9d538 --- apub_test.go +++ apub_test.go @@ -2,6 +2,9 @@ package apub import ( "os" + "path" + "reflect" + "sort" "testing" ) @@ -61,3 +64,36 @@ func TestTag(t *testing.T) { } } } + +func TestInboxes(t *testing.T) { + want := []string{ + "https://apubtest2.srcbeat.com/otl/inbox", + "https://hachyderm.io/inbox", + "https://lemmy.world/inbox", + "https://social.harpia.red/inbox", + } + + root := "testdata/actor" + dirent, err := os.ReadDir("testdata/actor") + if err != nil { + t.Fatal(err) + } + actors := make([]Actor, len(dirent)) + for i, ent := range dirent { + f, err := os.Open(path.Join(root, ent.Name())) + if err != nil { + t.Fatal(err) + } + defer f.Close() + a, err := DecodeActor(f) + if err != nil { + t.Fatal(err) + } + actors[i] = *a + } + got := Inboxes(actors) + sort.Strings(got) + if !reflect.DeepEqual(want, got) { + t.Errorf("unexpected inbox slice of multiple actors, want %s got %s", want, got) + } +} blob - 32791cec840b2b12d1e1e8d0b65ff70e891168eb blob + ebec2e71f4656e3901d092e4341455bc24af2c11 --- client.go +++ client.go @@ -4,13 +4,10 @@ import ( "bytes" "crypto/rsa" "encoding/json" - "errors" "fmt" "io" "net/http" - "net/url" "os" - "path" "strings" ) @@ -85,6 +82,7 @@ func activityToActor(activity *Activity) *Actor { Followers: activity.Followers, Published: activity.Published, Summary: activity.Summary, + Endpoints: activity.Endpoints, } if activity.PublicKey != nil { actor.PublicKey = *activity.PublicKey blob - 8eab875d3be01c461603b264459d99ef544b1609 blob + a7bbc2b8a8d81d50b7811433945040cbddfd9cb4 --- cmd/apsend/apsend.go +++ cmd/apsend/apsend.go @@ -153,18 +153,22 @@ func main() { log.Fatalf("append activities to outbox: %v", err) } + var actors []apub.Actor for _, rcpt := range remote { if strings.Contains(rcpt, "+followers") { rcpt = strings.Replace(rcpt, "+followers", "", 1) } - ra, err := client.Finger(rcpt) + a, err := client.Finger(rcpt) if err != nil { log.Printf("webfinger %s: %v", rcpt, err) gotErr = true continue } - if _, err = client.Send(ra.Inbox, create); err != nil { - log.Printf("send %s %s to %s: %v", activity.Type, activity.ID, rcpt, err) + actors = append(actors, *a) + } + for _, inbox := range apub.Inboxes(actors) { + if _, err = client.Send(inbox, create); err != nil { + log.Printf("send %s %s to %s: %v", activity.Type, activity.ID, inbox, err) gotErr = true } } blob - /dev/null blob + 9f50d2671b2ef00c52a2aafeacfbfdfc692f8bb6 (mode 644) --- /dev/null +++ testdata/actor/apas.json @@ -0,0 +1,18 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "id": "https://apubtest2.srcbeat.com/otl/actor.json", + "type": "Person", + "inbox": "https://apubtest2.srcbeat.com/otl/inbox", + "outbox": "https://apubtest2.srcbeat.com/otl/outbox", + "name": "Oliver Lowe", + "preferredUsername": "otl", + "publicKey": { + "id": "https://apubtest2.srcbeat.com/otl/actor.json#main-key", + "owner": "https://apubtest2.srcbeat.com/otl/actor.json", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAup/3R3YKTRAdhz/xX908\ns4nZKX9IwJfo8xKANbDrFkuBHHYggXV6bB3eVBGh6vDoGDVqmZ79ujXl8LMSo1dC\nVTo4GdBhCy3/BEtl3xoM80LEqmnamq72+tleCYe3a22YOkSLeTXTIREB4VfTataS\n6fVntVs1xDoR6YC+yR6PKqeFsEhQVcnFQ1Fk93nhPoC03RLqlmWkOWuhw4AhELCK\na6ydVEH8raevAbujJyRpZ7JoMtZy9NfuKI4bxorNga2LGnf3qyqS/gAFmGO/ncRu\nwqyhE1VWbtK7MRl3H+ltL/gwhaQMrHImCnWR1EvYw0nabp0519QaPoQoFWvdJbHv\nPwIDAQAB\n-----END PUBLIC KEY-----" + } +} + blob - /dev/null blob + 8bed4314314bf0e7ea7960053c42e785eda5eb79 (mode 644) --- /dev/null +++ testdata/actor/lemmy2.json @@ -0,0 +1,34 @@ +{ + "@context": [ + "https://join-lemmy.org/context.json", + "https://www.w3.org/ns/activitystreams" + ], + "type": "Person", + "id": "https://lemmy.world/u/Ghostalmedia", + "preferredUsername": "Ghostalmedia", + "inbox": "https://lemmy.world/u/Ghostalmedia/inbox", + "outbox": "https://lemmy.world/u/Ghostalmedia/outbox", + "publicKey": { + "id": "https://lemmy.world/u/Ghostalmedia#main-key", + "owner": "https://lemmy.world/u/Ghostalmedia", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu04IUDC0EqVJ+laJ3TQc\ng70fuBRhjdyT4rVnAR0oNRvOedNEW7aOgNWmC/q1Kgzmya83HKLq7ZvB6Z2kz/tS\nTObQ6P7+sDlDv9l65mmC+oi++ji/2X37HvPAJ71iKs+ky4IaGZ67ygoAwcEvfJnv\nBGELomuzwR0/CqILeSDeceAZYZVUploXKwzHdX4efQGrNWNzglZZNKkPuv4/s5Mi\nwEOUOmTVp7yjSMOGB6ImuSv81hDszIAxptUXXjDd5oEAcQR6gWHlwMG0V7Ih7jyM\nZeyJXWZ7kS3+4/mcpr1MAeupQBjHSjnFSOiE+1bG8OkKg7B6rhj2JKRcQDdSaHD8\nrwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "name": "Ghostalmedia", + "summary": "

I’m from space!

\n", + "source": { + "content": "I'm from space!", + "mediaType": "text/markdown" + }, + "icon": { + "type": "Image", + "url": "https://lemmy.world/pictrs/image/e78bbe19-dc73-490e-b9d9-c1ced432f002.jpeg" + }, + "image": { + "type": "Image", + "url": "https://lemmy.world/pictrs/image/da47b488-57a9-49fc-9d58-4ad4d471e0cc.jpeg" + }, + "endpoints": { + "sharedInbox": "https://lemmy.world/inbox" + }, + "published": "2023-06-10T18:40:35.038963Z" +} \ No newline at end of file