Commit Diff


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": "<p>I’m from space!</p>\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