Commit Diff


commit - /dev/null
commit + 1d5ddf5d08ecf1b7bf60c8d4c9eefeda181183e3
blob - /dev/null
blob + e5b0d54e78ab010295f99484817e340f8cb316b7 (mode 644)
--- /dev/null
+++ apub.go
@@ -0,0 +1,121 @@
+// apub is an implementation of the ActivityPub protocol.
+//
+// https://www.w3.org/TR/activitypub/
+// https://www.w3.org/TR/activitystreams-core/
+// https://www.w3.org/TR/activitystreams-vocabulary/
+package apub
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/mail"
+	"strings"
+	"time"
+)
+
+// @context
+const AtContext string = "https://www.w3.org/ns/activitystreams"
+
+const ContentType string = "application/activity+json"
+
+const AcceptMediaType string = `application/activity+json; profile="https://www.w3.org/ns/activitystreams"`
+
+const ToEveryone string = "https://www.w3.org/ns/activitystreams#Public"
+
+type Activity struct {
+	AtContext    string     `json:"@context"`
+	ID           string     `json:"id"`
+	Type         string     `json:"type"`
+	Name         string     `json:"name,omitempty"`
+	Actor        string     `json:"actor,omitempty"`
+	Username     string     `json:"preferredUsername,omitempty"`
+	Inbox        string     `json:"inbox,omitempty"`
+	Outbox       string     `json:"outbox,omitempty"`
+	To           []string   `json:"to,omitempty"`
+	CC           []string   `json:"cc,omitempty"`
+	InReplyTo    string     `json:"inReplyTo,omitempty"`
+	Published    *time.Time `json:"published,omitempty"`
+	AttributedTo string     `json:"attributedTo,omitempty"`
+	Content      string     `json:"content,omitempty"`
+	MediaType    string     `json:"mediaType,omitempty"`
+	Source       struct {
+		Content   string `json:"content,omitempty"`
+		MediaType string `json:"mediaType,omitempty"`
+	} `json:"source,omitempty"`
+	Audience string          `json:"audience,omitempty"`
+	Object   json.RawMessage `json:"object,omitempty"`
+}
+
+func (act *Activity) UnmarshalJSON(b []byte) error {
+	type Alias Activity
+	aux := &struct {
+		AtContext interface{} `json:"@context"`
+		Object    interface{}
+		*Alias
+	}{
+		Alias: (*Alias)(act),
+	}
+	if err := json.Unmarshal(b, &aux); err != nil {
+		return err
+	}
+	switch v := aux.AtContext.(type) {
+	case string:
+		act.AtContext = v
+	case []interface{}:
+		if vv, ok := v[0].(string); ok {
+			act.AtContext = vv
+		}
+	}
+	return nil
+}
+
+func (act *Activity) Unwrap(client *Client) (*Activity, error) {
+	if act.Object == nil {
+		return nil, errors.New("no wrapped activity")
+	}
+
+	var buf io.Reader
+	buf = bytes.NewReader(act.Object)
+	if strings.HasPrefix(string(act.Object), "https") {
+		if client == nil {
+			return Lookup(string(act.Object))
+		}
+		return client.Lookup(string(act.Object))
+	}
+	return Decode(buf)
+}
+
+func Decode(r io.Reader) (*Activity, error) {
+	var a Activity
+	if err := json.NewDecoder(r).Decode(&a); err != nil {
+		return nil, fmt.Errorf("decode activity: %w", err)
+	}
+	return &a, nil
+}
+
+type Actor struct {
+	AtContext string    `json:"@context"`
+	ID        string    `json:"id"`
+	Type      string    `json:"type"`
+	Name      string    `json:"name"`
+	Username  string    `json:"preferredUsername"`
+	Inbox     string    `json:"inbox"`
+	Outbox    string    `json:"outbox"`
+	PublicKey PublicKey `json:"publicKey"`
+}
+
+type PublicKey struct {
+	ID           string `json:"id"`
+	Owner        string `json:"owner"`
+	PublicKeyPEM string `json:"publicKeyPem"`
+}
+
+func (a *Actor) Address() *mail.Address {
+	trimmed := strings.TrimPrefix(a.ID, "https://")
+	host, _, _ := strings.Cut(trimmed, "/")
+	addr := fmt.Sprintf("%s@%s", a.Username, host)
+	return &mail.Address{a.Name, addr}
+}
blob - /dev/null
blob + bb13abcf08f99d617d2ae32bfa06d615320cf921 (mode 644)
--- /dev/null
+++ apub_test.go
@@ -0,0 +1,23 @@
+package apub
+
+import (
+	"os"
+	"testing"
+)
+
+func TestDecode(t *testing.T) {
+	samples := []string{"testdata/announce1.json", "testdata/note.json"}
+	for _, name := range samples {
+		f, err := os.Open(name)
+		if err != nil {
+			t.Error(err)
+			continue
+		}
+		defer f.Close()
+		a, err := Decode(f)
+		if err != nil {
+			t.Fatal(err)
+		}
+		t.Logf("%+v", a)
+	}
+}
blob - /dev/null
blob + 49dbb7da0f2bd6225226119d717b06ac7f8ff56b (mode 644)
--- /dev/null
+++ client.go
@@ -0,0 +1,113 @@
+package apub
+
+import (
+	"bytes"
+	"crypto/rsa"
+	"io"
+	"os"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"strings"
+)
+
+var defaultClient Client = Client{Client: http.DefaultClient}
+
+func Lookup(id string) (*Activity, error) {
+	return defaultClient.Lookup(id)
+}
+
+func LookupActor(id string) (*Actor, error) {
+	return defaultClient.LookupActor(id)
+}
+
+type Client struct {
+	*http.Client
+	Key   *rsa.PrivateKey
+	Actor *Actor
+}
+
+func (c *Client) Lookup(id string) (*Activity, error) {
+	if !strings.HasPrefix(id, "http") {
+		return nil, fmt.Errorf("id is not a HTTP URL")
+	}
+	if c.Client == nil {
+		c.Client = http.DefaultClient
+	}
+
+	req, err := http.NewRequest(http.MethodGet, id, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", ContentType)
+	if c.Key != nil && c.Actor != nil {
+		if err := Sign(req, c.Key, c.Actor.PublicKey.ID); err != nil {
+			return nil, fmt.Errorf("sign http request: %w", err)
+		}
+	}
+	resp, err := c.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode == http.StatusNotFound {
+		return nil, fmt.Errorf("no such activity")
+	} else if resp.StatusCode >= 400 {
+		return nil, fmt.Errorf("non-ok response status %s", resp.Status)
+	}
+	return Decode(resp.Body)
+}
+
+func (c *Client) LookupActor(id string) (*Actor, error) {
+	activity, err := c.Lookup(id)
+	if err != nil {
+		return nil, err
+	}
+	return &Actor{
+		AtContext: activity.AtContext,
+		ID:        activity.ID,
+		Type:      activity.Type,
+		Name:      activity.Name,
+		Username:  activity.Username,
+		Inbox:     activity.Inbox,
+		Outbox:    activity.Outbox,
+	}, nil
+}
+
+func (c *Client) Send(inbox string, activity *Activity) (*Activity, error) {
+	b, err := json.Marshal(activity)
+	if err != nil {
+		return nil, fmt.Errorf("encode outgoing activity: %w", err)
+	}
+	req, err := http.NewRequest(http.MethodPost, inbox, bytes.NewReader(b))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", ContentType)
+	req.Header.Set("Accept", ContentType)
+	if err := Sign(req, c.Key, c.Actor.PublicKey.ID); err != nil {
+		return nil, fmt.Errorf("sign outgoing request: %w", err)
+	}
+	resp, err := c.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	log.Println(req.Method, req.URL, resp.Status)
+	switch resp.StatusCode {
+	case http.StatusOK:
+		if resp.ContentLength == 0 {
+			return nil, nil
+		}
+		defer resp.Body.Close()
+		return Decode(resp.Body)
+	case http.StatusAccepted, http.StatusNoContent:
+		return nil, nil
+	case http.StatusNotFound:
+		return nil, fmt.Errorf("no such inbox %s", inbox)
+	default:
+		io.Copy(os.Stderr, resp.Body)
+		resp.Body.Close()
+		return nil, fmt.Errorf("non-ok response status %s", resp.Status)
+	}
+}
blob - /dev/null
blob + 3210efbe24849cd75541ae0d14bf766f8224ddc9 (mode 644)
--- /dev/null
+++ cmd/listen/listen.go
@@ -0,0 +1,151 @@
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/fs"
+	"log"
+	"net/http"
+	"os"
+	"path"
+	"time"
+
+	"olowe.co/apub"
+)
+
+type server struct {
+	fsRoot string
+}
+
+func (srv *server) handleReceived(activity *apub.Activity) {
+	var err error
+	switch activity.Type {
+	default:
+		return
+	case "Note":
+		// check if we need to dereference
+		if activity.Content == "" {
+			activity, err = apub.Lookup(activity.ID)
+			if err != nil {
+				log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err)
+				return
+			}
+		}
+	case "Page":
+		// check if we need to dereference
+		if activity.Name == "" {
+			activity, err = apub.Lookup(activity.ID)
+			if err != nil {
+				log.Printf("dereference %s %s: %v", activity.Type, activity.ID, err)
+				return
+			}
+		}
+	case "Create", "Update":
+		wrapped, err := activity.Unwrap(nil)
+		if err != nil {
+			log.Printf("unwrap apub in %s: %v", wrapped.ID, err)
+			return
+		}
+		srv.handleReceived(wrapped)
+		return
+	}
+	if err := srv.deliver(activity); err != nil {
+		log.Printf("deliver %s %s: %v", activity.Type, activity.ID, err)
+	}
+}
+
+func (srv *server) handleInbox(w http.ResponseWriter, req *http.Request) {
+	if req.Method != http.MethodPost {
+		stat := http.StatusMethodNotAllowed
+		http.Error(w, http.StatusText(stat), stat)
+		return
+	}
+	if req.Header.Get("Content-Type") != apub.ContentType {
+		stat := http.StatusUnsupportedMediaType
+		http.Error(w, http.StatusText(stat), stat)
+		return
+	}
+	defer req.Body.Close()
+	var rcv apub.Activity // received
+	if err := json.NewDecoder(req.Body).Decode(&rcv); err != nil {
+		log.Println("decode apub message:", err)
+		stat := http.StatusBadRequest
+		http.Error(w, "malformed activitypub message", stat)
+		return
+	}
+	activity := &rcv
+	if rcv.Type == "Announce" {
+		var err error
+		activity, err = rcv.Unwrap(nil)
+		if err != nil {
+			err = fmt.Errorf("unwrap apub object in %s: %w", rcv.ID, err)
+			stat := http.StatusBadRequest
+			http.Error(w, err.Error(), stat)
+			return
+		}
+	}
+	switch activity.Type {
+	case "Like", "Dislike", "Delete", "Accept", "Reject":
+		w.WriteHeader(http.StatusAccepted)
+		return
+	case "Create", "Update", "Note", "Page", "Article":
+		w.WriteHeader(http.StatusAccepted)
+		srv.handleReceived(activity)
+		return
+	}
+	w.WriteHeader(http.StatusNotImplemented)
+}
+
+func (srv *server) deliver(a *apub.Activity) error {
+	name := fmt.Sprintf("%d.json", time.Now().UnixNano())
+	name = path.Join(srv.fsRoot, "inbox", name)
+	f, err := os.Create(name)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	if err := json.NewEncoder(f).Encode(a); err != nil {
+		return err
+	}
+	return nil
+}
+
+	/*
+	p, err := apub.MarshalMail(a)
+	if err != nil {
+		return fmt.Errorf("marshal mail message: %w", err)
+	}
+	now := time.Now().Unix()
+	seq := 0
+	max := 99
+	name := fmt.Sprintf("%d.%02d", now, seq)
+	name = path.Join(srv.fsRoot, "inbox", name)
+	for seq <= max {
+		name = fmt.Sprintf("%d.%02d", now, seq)
+		name = path.Join(srv.fsRoot, "inbox", name)
+		_, err := os.Stat(name)
+		if err == nil {
+			seq++
+			continue
+		} else if errors.Is(err, fs.ErrNotExist) {
+			break
+		}
+		return fmt.Errorf("get unique mdir name: %w", err)
+	}
+	if seq >= max {
+		return fmt.Errorf("infinite loop to get uniqe mdir name")
+	}
+	return os.WriteFile(name, p, 0644)
+}
+	*/
+
+var home string = os.Getenv("HOME")
+
+func main() {
+	srv := &server{fsRoot: home+"/apubtest"}
+	fsys := os.DirFS(srv.fsRoot)
+	http.Handle("/", http.FileServer(http.FS(fsys)))
+	http.HandleFunc("/inbox", srv.handleInbox)
+	log.Fatal(http.ListenAndServe("[::1]:8082", nil))
+}
blob - /dev/null
blob + d8dd1836aa16d8274d27b5a1480df17040a55f80 (mode 644)
--- /dev/null
+++ go.mod
@@ -0,0 +1,3 @@
+module olowe.co/apub
+
+go 1.19
blob - /dev/null
blob + e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 (mode 644)
blob - /dev/null
blob + 42494826d064e201e5fddabbeed9df2311342b34 (mode 644)
--- /dev/null
+++ mail.go
@@ -0,0 +1,61 @@
+package apub
+
+import (
+	"bytes"
+	"fmt"
+	"net/mail"
+	"strings"
+	"time"
+)
+
+func MarshalMail(activity *Activity) ([]byte, error) {
+	buf := &bytes.Buffer{}
+
+	actor, err := LookupActor(activity.AttributedTo)
+	if err != nil {
+		return nil, fmt.Errorf("lookup actor %s: %w", activity.AttributedTo, err)
+	}
+	fmt.Fprintf(buf, "From: %s\n", actor.Address())
+
+	if activity.CC != nil {
+		buf.WriteString("To: ")
+		rcpt := append(activity.To, activity.CC...)
+		var addrs []string
+		for _, u := range rcpt {
+			if u == ToEveryone {
+				continue
+			}
+			actor, err = LookupActor(u)
+			if err != nil {
+				return nil, fmt.Errorf("lookup actor %s: %w", u, err)
+			}
+			addrs = append(addrs, actor.Address().String())
+		}
+		buf.WriteString(strings.Join(addrs, ", "))
+		buf.WriteString("\n")
+	}
+
+	fmt.Fprintf(buf, "Date: %s\n", activity.Published.Format(time.RFC822))
+	fmt.Fprintf(buf, "Message-ID: <%s>\n", activity.ID)
+	if activity.Audience != "" {
+		fmt.Fprintf(buf, "List-ID: <%s>\n", activity.Audience)
+	}
+	if activity.InReplyTo != "" {
+		fmt.Fprintf(buf, "References: <%s>\n", activity.InReplyTo)
+	}
+
+	if activity.Source.Content != "" && activity.Source.MediaType == "text/markdown" {
+		fmt.Fprintln(buf, "Content-Type: text/plain; charset=utf-8")
+	} else {
+		fmt.Fprintln(buf, "Content-Type:", activity.MediaType)
+	}
+	fmt.Fprintln(buf, "Subject:", activity.Name)
+	fmt.Fprintln(buf)
+	if activity.Source.Content != "" && activity.Source.MediaType == "text/markdown" {
+		fmt.Fprintln(buf, activity.Source.Content)
+	} else {
+		fmt.Fprintln(buf, activity.Content)
+	}
+	_, err = mail.ReadMessage(bytes.NewReader(buf.Bytes()))
+	return buf.Bytes(), err
+}
blob - /dev/null
blob + 235301076f553c91b09cb0e478277d2bab4ac4c5 (mode 644)
--- /dev/null
+++ mail_test.go
@@ -0,0 +1,39 @@
+package apub
+
+import (
+	"bytes"
+	"net/mail"
+	"os"
+	"testing"
+)
+
+func TestMail(t *testing.T) {
+	want := "<Spotlight7573@lemmy.world>"
+
+	url := "https://lemmy.world/u/Spotlight7573"
+	actor, err := LookupActor(url)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if actor.Address().String() != want {
+		t.Errorf("got %s, want %s", actor.Address().String(), want)
+	}
+
+	f, err := os.Open("testdata/note.json")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	activity, err := Decode(f)
+	if err != nil {
+		t.Fatal(err)
+	}
+	b, err := MarshalMail(activity)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(string(b))
+	if _, err := mail.ReadMessage(bytes.NewReader(b)); err != nil {
+		t.Fatal(err)
+	}
+}
blob - /dev/null
blob + 10ad89428310f2f22e18e031087c73487f173676 (mode 644)
--- /dev/null
+++ mastodon/mastodon.go
@@ -0,0 +1,110 @@
+package mastodon
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/mail"
+	"net/url"
+	"strings"
+	"time"
+)
+
+type Post struct {
+	CreatedAt time.Time `json:"created_at"`
+	ID        string    `json:"id"`
+	InReplyTo string    `json:"in_reply_to_id"`
+	Content   string    `json:"content"`
+}
+
+// DecodeMail decodes the RFC822 message-encoded post from r.
+func DecodeMail(r io.Reader) (*Post, error) {
+	msg, err := mail.ReadMessage(r)
+	if err != nil {
+		panic(err)
+		return nil, err
+	}
+	var post Post
+	post.InReplyTo = msg.Header.Get("In-Reply-To")
+	buf := &bytes.Buffer{}
+	if msg.Header.Get("Subject") != "" {
+		fmt.Fprintln(buf, msg.Header.Get("Subject"))
+	}
+	if _, err := io.Copy(buf, msg.Body); err != nil {
+		return nil, fmt.Errorf("read message body: %w", err)
+	}
+	rcpt, err := msg.Header.AddressList("To")
+	if err != nil {
+		return nil, fmt.Errorf("parse To field: %w", err)
+	}
+	if msg.Header.Get("CC") != "" {
+		rr, err := msg.Header.AddressList("CC")
+		if err != nil {
+			return nil, fmt.Errorf("parse CC field: %w", err)
+		}
+		rcpt = append(rcpt, rr...)
+	}
+	addrs := make([]string, len(rcpt))
+	for i := range rcpt {
+		addrs[i] = "@" + rcpt[i].Address
+	}
+	fmt.Fprintln(buf, strings.Join(addrs, " "))
+	post.Content = buf.String()
+	return &post, nil
+}
+
+func Send(apiRoot, token string, p *Post) error {
+	form := make(url.Values)
+	if p.InReplyTo != "" {
+		form.Set("in_reply_to_id", p.InReplyTo)
+	}
+	form.Set("status", p.Content)
+	req, err := http.NewRequest(http.MethodPost, apiRoot+"/v1/statuses", strings.NewReader(form.Encode()))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Authorization", "Bearer "+token)
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode >= 400 {
+		return fmt.Errorf("non-ok remote status: %s", resp.Status)
+	}
+	return nil
+}
+
+func Search(apiRoot, token, query string) ([]Post, error) {
+	q := make(url.Values)
+	q.Set("resolve", "1") // fetch apub objects given as a URL
+	q.Set("q", query)
+	u, err := url.Parse(apiRoot + "/v2/search")
+	if err != nil {
+		return nil, err
+	}
+	u.RawQuery = q.Encode()
+	req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+	if err != nil {
+		return nil, err
+	}
+	if token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	if resp.StatusCode >= 400 {
+		return nil, fmt.Errorf("non-ok response status %s", resp.Status)
+	}
+	defer resp.Body.Close()
+	found := struct {
+		Statuses []Post
+	}{}
+	if err := json.NewDecoder(resp.Body).Decode(&found); err != nil {
+		return nil, fmt.Errorf("decode search results: %w", err)
+	}
+	return found.Statuses, nil
+}
blob - /dev/null
blob + b54531fc2b3437fac05911013623f936dcabbb1d (mode 644)
--- /dev/null
+++ mastodon/mastodon_test.go
@@ -0,0 +1,29 @@
+package mastodon
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestMail(t *testing.T) {
+	r := strings.NewReader(`Date: Mon, 23 Jun 2015 11:40:36 -0400
+From: Gopher <from@example.com>
+To: Another Gopher <to@example.com>
+Subject: Gophers at Gophercon
+
+Message body`)
+	if _, err := DecodeMail(r); err != nil {
+		t.Error(err)
+	}
+}
+
+func TestSearch(t *testing.T) {
+	root := "https://hachyderm.io/api"
+	token := "T3sIJ3WIY7HjnGODcqE4_tOzDMJGtIcaFzguN511z84"
+	q := "#spam"
+	posts, err := Search(root, token, q)
+	if err != nil {
+		t.Errorf("search query %q: %v", q, err)
+	}
+	t.Log(posts)
+}
blob - /dev/null
blob + 3a1cec9332e93a2092f1280ed7f9164d03bfcf60 (mode 644)
--- /dev/null
+++ post.go
@@ -0,0 +1,93 @@
+package apub
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/http/httputil"
+	"time"
+)
+
+// Sign signs the given HTTP request with the matching private key of the
+// public key available at pubkeyURL.
+func Sign(req *http.Request, key *rsa.PrivateKey, pubkeyURL string) error {
+	date := time.Now().UTC().Format(http.TimeFormat)
+	hash := sha256.New()
+	fmt.Fprintf(hash, "(request-target): post %s\n", req.URL.Path)
+	fmt.Fprintf(hash, "host: %s\n", req.URL.Host)
+	fmt.Fprintf(hash, "date: %s", date)
+	if req.Method == http.MethodPost {
+		buf := &bytes.Buffer{}
+		io.Copy(buf, req.Body)
+		req.Body.Close()
+		req.Body = io.NopCloser(buf)
+		digest := sha256.Sum256(buf.Bytes())
+		d := fmt.Sprintf("sha-256=%s", base64.StdEncoding.EncodeToString(digest[:]))
+		// append a newline to the "date" key as we assumed we didn't
+		// need one before.
+		fmt.Fprintf(hash, "\n")
+		fmt.Fprintf(hash, "digest: %s", d)
+		req.Header.Set("Digest", d)
+	}
+	sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash.Sum(nil))
+	if err != nil {
+		return err
+	}
+
+	sigKeys := "(request-target) host date"
+	if req.Method == http.MethodPost {
+		sigKeys += " digest"
+	}
+	val := fmt.Sprintf("keyId=%q,headers=%q,signature=%q", pubkeyURL, sigKeys, base64.StdEncoding.EncodeToString(sig))
+
+	req.Header.Set("Signature", val)
+	req.Header.Set("Date", date)
+	return nil
+}
+
+func Post(inbox string, key *rsa.PrivateKey, activity *Activity) error {
+	body := &bytes.Buffer{}
+	if err := json.NewEncoder(body).Encode(activity); err != nil {
+		return fmt.Errorf("encode activity: %w", err)
+	}
+	req, err := http.NewRequest(http.MethodPost, inbox, body)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", ContentType)
+	/*
+		if err := Sign(req, key, activity.Object.AttributedTo+"#main-key"); err != nil {
+			return fmt.Errorf("sign request: %w", err)
+		}
+	*/
+
+	b, err := httputil.DumpRequestOut(req, true)
+	if err != nil {
+		log.Fatal(err)
+	}
+	fmt.Println(string(b))
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	b, err = httputil.DumpResponse(resp, true)
+	if err != nil {
+		log.Fatal(err)
+	}
+	fmt.Println(string(b))
+
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("non-ok response status %s", resp.Status)
+	}
+	return nil
+}
blob - /dev/null
blob + 6c5a6e55945fadfc2144d0dca75d21a0c23c1f45 (mode 644)
--- /dev/null
+++ testdata/actor.json
@@ -0,0 +1,112 @@
+{
+    "@context": [
+        "https://www.w3.org/ns/activitystreams",
+        "https://w3id.org/security/v1",
+        {
+            "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+            "toot": "http://joinmastodon.org/ns#",
+            "featured": {
+                "@id": "toot:featured",
+                "@type": "@id"
+            },
+            "featuredTags": {
+                "@id": "toot:featuredTags",
+                "@type": "@id"
+            },
+            "alsoKnownAs": {
+                "@id": "as:alsoKnownAs",
+                "@type": "@id"
+            },
+            "movedTo": {
+                "@id": "as:movedTo",
+                "@type": "@id"
+            },
+            "schema": "http://schema.org#",
+            "PropertyValue": "schema:PropertyValue",
+            "value": "schema:value",
+            "discoverable": "toot:discoverable",
+            "Device": "toot:Device",
+            "Ed25519Signature": "toot:Ed25519Signature",
+            "Ed25519Key": "toot:Ed25519Key",
+            "Curve25519Key": "toot:Curve25519Key",
+            "EncryptedMessage": "toot:EncryptedMessage",
+            "publicKeyBase64": "toot:publicKeyBase64",
+            "deviceId": "toot:deviceId",
+            "claim": {
+                "@type": "@id",
+                "@id": "toot:claim"
+            },
+            "fingerprintKey": {
+                "@type": "@id",
+                "@id": "toot:fingerprintKey"
+            },
+            "identityKey": {
+                "@type": "@id",
+                "@id": "toot:identityKey"
+            },
+            "devices": {
+                "@type": "@id",
+                "@id": "toot:devices"
+            },
+            "messageFranking": "toot:messageFranking",
+            "messageType": "toot:messageType",
+            "cipherText": "toot:cipherText",
+            "suspended": "toot:suspended",
+            "memorial": "toot:memorial",
+            "indexable": "toot:indexable",
+            "focalPoint": {
+                "@container": "@list",
+                "@id": "toot:focalPoint"
+            }
+        }
+    ],
+    "id": "https://hachyderm.io/users/otl",
+    "type": "Person",
+    "following": "https://hachyderm.io/users/otl/following",
+    "followers": "https://hachyderm.io/users/otl/followers",
+    "inbox": "https://hachyderm.io/users/otl/inbox",
+    "outbox": "https://hachyderm.io/users/otl/outbox",
+    "featured": "https://hachyderm.io/users/otl/collections/featured",
+    "featuredTags": "https://hachyderm.io/users/otl/collections/tags",
+    "preferredUsername": "otl",
+    "name": "Oliver Lowe",
+    "summary": "<p>Rollerblading, programming, writing, documentaries, travel, motorbikes\u2026 That\u2019s it!</p><p>Preferably o@gts.olowe.co.<br />This account is here to interact with bits of the Fediverse which don&#39;t play nicely with GoToSocial.</p>",
+    "url": "https://hachyderm.io/@otl",
+    "manuallyApprovesFollowers": false,
+    "discoverable": true,
+    "indexable": true,
+    "published": "2023-01-21T00:00:00Z",
+    "memorial": false,
+    "devices": "https://hachyderm.io/users/otl/collections/devices",
+    "publicKey": {
+        "id": "https://hachyderm.io/users/otl#main-key",
+        "owner": "https://hachyderm.io/users/otl",
+        "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Yx6ZYDHNiBTyj2pQYZt\nR61AefGMZ9e9hlTvymqt11dzGvFZww42zPzIiiGM3SedEBhZ9hoYEu3YqSu7HLos\nlRdqTp30SzAo0tF5F/S90CQ3jhoblvjerNv8b9R3Fs79galDDvNcrf27efe1dFQ+\nHNWv6vvWILzP91IHOrM+JvLWTgpO+E1a14ez5qNrNIoUBVktiqAF9uAdghuseoM4\nX+CeUx6NnBDcM0M/YhUqM3AWtThrncp5LFa9wW9BvvhBNEaA+ElreTFVKryXaPAK\nyMoa9Lar1JAo54rltPudv3tSIcLG40JQAdD/0nbdcObVNLRiPJcfeHf+62ELiQkZ\nVwIDAQAB\n-----END PUBLIC KEY-----\n"
+    },
+    "tag": [],
+    "attachment": [
+        {
+            "type": "PropertyValue",
+            "name": "web",
+            "value": "www.olowe.co"
+        },
+        {
+            "type": "PropertyValue",
+            "name": "fediverse",
+            "value": "o@gts.olowe.co"
+        }
+    ],
+    "endpoints": {
+        "sharedInbox": "https://hachyderm.io/inbox"
+    },
+    "icon": {
+        "type": "Image",
+        "mediaType": "image/png",
+        "url": "https://media.hachyderm.io/accounts/avatars/109/729/649/989/499/669/original/052ab0fab12fd69c.png"
+    },
+    "image": {
+        "type": "Image",
+        "mediaType": "image/gif",
+        "url": "https://media.hachyderm.io/accounts/headers/109/729/649/989/499/669/original/f23d8e6fc39d47ab.gif"
+    }
+}
blob - /dev/null
blob + d3d82c3324bd58a9ca422147801d0636952524ed (mode 644)
--- /dev/null
+++ testdata/announce1.json
@@ -0,0 +1,47 @@
+{
+    "@context": [
+        "https://www.w3.org/ns/activitystreams",
+        "https://w3id.org/security/v1",
+        {
+            "lemmy": "https://join-lemmy.org/ns#",
+            "litepub": "http://litepub.social/ns#",
+            "pt": "https://joinpeertube.org/ns#",
+            "sc": "http://schema.org/",
+            "ChatMessage": "litepub:ChatMessage",
+            "commentsEnabled": "pt:commentsEnabled",
+            "sensitive": "as:sensitive",
+            "matrixUserId": "lemmy:matrixUserId",
+            "postingRestrictedToMods": "lemmy:postingRestrictedToMods",
+            "removeData": "lemmy:removeData",
+            "stickied": "lemmy:stickied",
+            "moderators": {
+                "@type": "@id",
+                "@id": "lemmy:moderators"
+            },
+            "expires": "as:endTime",
+            "distinguished": "lemmy:distinguished",
+            "language": "sc:inLanguage",
+            "identifier": "sc:identifier"
+        }
+    ],
+    "actor": "https://sh.itjust.works/c/test",
+    "to": [
+        "https://www.w3.org/ns/activitystreams#Public"
+    ],
+    "object": {
+        "id": "https://lemmy.sdf.org/activities/like/b5bd1577-9677-4130-8312-cd2e2fd4ea44",
+        "actor": "https://lemmy.sdf.org/u/otl",
+        "@context": [
+            "https://join-lemmy.org/context.json",
+            "https://www.w3.org/ns/activitystreams"
+        ],
+        "object": "https://lemmy.sdf.org/comment/8402084",
+        "type": "Like",
+        "audience": "https://sh.itjust.works/c/test"
+    },
+    "cc": [
+        "https://sh.itjust.works/c/test/followers"
+    ],
+    "type": "Announce",
+    "id": "https://sh.itjust.works/activities/announce/2e1a0c57-b078-492f-95ce-6be5fb56974e"
+}
blob - /dev/null
blob + 1a45286634a33a021bd1e7505b9473a852208e00 (mode 644)
--- /dev/null
+++ testdata/following
@@ -0,0 +1,7 @@
+{
+    "@context": "https://www.w3.org/ns/activitystreams",
+    "id": "https://hachyderm.io/users/otl/following",
+    "type": "OrderedCollection",
+    "totalItems": 67,
+    "first": "https://hachyderm.io/users/otl/following?page=1"
+}
blob - /dev/null
blob + 52ac82b12c87f95f8c8a0cdcf475f46d810c25da (mode 644)
--- /dev/null
+++ testdata/following1
@@ -0,0 +1,22 @@
+{
+    "@context": "https://www.w3.org/ns/activitystreams",
+    "id": "https://hachyderm.io/users/otl/following?page=1",
+    "type": "OrderedCollectionPage",
+    "totalItems": 67,
+    "next": "https://hachyderm.io/users/otl/following?page=2",
+    "partOf": "https://hachyderm.io/users/otl/following",
+    "orderedItems": [
+        "https://programming.dev/c/cs_career_questions",
+        "https://lemmy.ml/c/mechanicalkeyboards",
+        "https://piefed.social/c/playground",
+        "https://gts.olowe.co/users/o",
+        "https://hachyderm.io/users/nabeards",
+        "https://fosstodon.org/users/edgren",
+        "https://bsd.network/users/OpenBSDAms",
+        "https://social.growyourown.services/users/FediTips",
+        "https://www.threads.net/ap/users/mosseri/",
+        "https://fosstodon.org/users/mvdan",
+        "https://lemmy.srcbeat.com/u/otl",
+        "https://chaos.social/users/Merovius"
+    ]
+}
blob - /dev/null
blob + 6aea9443a4186947f42ae983f26dbe41b317c9a3 (mode 644)
--- /dev/null
+++ testdata/note.json
@@ -0,0 +1,33 @@
+{
+    "type": "Note",
+    "id": "https://lemmy.world/comment/7547183",
+    "attributedTo": "https://lemmy.world/u/Spotlight7573",
+    "to": [
+        "https://www.w3.org/ns/activitystreams#Public"
+    ],
+    "cc": [
+        "https://lemmy.world/c/technology",
+        "https://lemmy.world/u/Feathercrown"
+    ],
+    "content": "<p>Passkeys are protected by either your device\u2019s password/passcode (something you know) or your device\u2019s biometrics (something you are). That provides two factors when combined with the passkey itself (something you have).</p>\n<p>The benefit of the password is only available if you know your password for your accounts or if you have a password manager. People can only remember a limited number of passwords without resorting to systems or patterns. Additionally, with many accounts now knowing the password is not enough to log in, you must either be logging in from an existing device or perform some kind of 2FA (TOTP, SMS, hardware security key, etc). So you already need to have a backup device to log in anyways. Same with a password manager: if you can have a copy of your vault with your <em>password</em> on another device then you can have a copy of your vault with your <em>passkey</em> on another device. Nothing gets rid of the requirement to have a backup device or copy of your passwords/passkeys if you want to avoid being locked out.</p>\n",
+    "inReplyTo": "https://lemmy.world/comment/7535501",
+    "mediaType": "text/html",
+    "source": {
+        "content": "Passkeys are protected by either your device's password/passcode (something you know) or your device's biometrics (something you are). That provides two factors when combined with the passkey itself (something you have).\n\nThe benefit of the password is only available if you know your password for your accounts or if you have a password manager. People can only remember a limited number of passwords without resorting to systems or patterns. Additionally, with many accounts now knowing the password is not enough to log in, you must either be logging in from an existing device or perform some kind of 2FA (TOTP, SMS, hardware security key, etc). So you already need to have a backup device to log in anyways. Same with a password manager: if you can have a copy of your vault with your *password* on another device then you can have a copy of your vault with your *passkey* on another device. Nothing gets rid of the requirement to have a backup device or copy of your passwords/passkeys if you want to avoid being locked out.",
+        "mediaType": "text/markdown"
+    },
+    "published": "2024-02-15T05:18:23.084123+00:00",
+    "tag": [
+        {
+            "href": "https://lemmy.world/u/Feathercrown",
+            "name": "@Feathercrown@lemmy.world",
+            "type": "Mention"
+        }
+    ],
+    "distinguished": false,
+    "language": {
+        "identifier": "en",
+        "name": "English"
+    },
+    "audience": "https://lemmy.world/c/technology"
+}
blob - /dev/null
blob + 016fdd89845916a3f8ac2f945b3712ae746f70e9 (mode 644)
--- /dev/null
+++ testdata/page.json
@@ -0,0 +1,55 @@
+{
+    "@context": [
+        "https://www.w3.org/ns/activitystreams",
+        "https://w3id.org/security/v1",
+        {
+            "lemmy": "https://join-lemmy.org/ns#",
+            "litepub": "http://litepub.social/ns#",
+            "pt": "https://joinpeertube.org/ns#",
+            "sc": "http://schema.org/",
+            "ChatMessage": "litepub:ChatMessage",
+            "commentsEnabled": "pt:commentsEnabled",
+            "sensitive": "as:sensitive",
+            "matrixUserId": "lemmy:matrixUserId",
+            "postingRestrictedToMods": "lemmy:postingRestrictedToMods",
+            "removeData": "lemmy:removeData",
+            "stickied": "lemmy:stickied",
+            "moderators": {
+                "@type": "@id",
+                "@id": "lemmy:moderators"
+            },
+            "expires": "as:endTime",
+            "distinguished": "lemmy:distinguished",
+            "language": "sc:inLanguage",
+            "identifier": "sc:identifier"
+        }
+    ],
+    "type": "Page",
+    "id": "https://lemmy.world/post/11959769",
+    "attributedTo": "https://lemmy.world/u/FlyingSquid",
+    "to": [
+        "https://lemmy.world/c/technology",
+        "https://www.w3.org/ns/activitystreams#Public"
+    ],
+    "name": "Your AI Girlfriend Is a Data-Harvesting Horror Show",
+    "cc": [],
+    "mediaType": "text/html",
+    "attachment": [
+        {
+            "href": "https://gizmodo.com/your-ai-girlfriend-is-a-data-harvesting-horror-show-1851253284",
+            "type": "Link"
+        }
+    ],
+    "image": {
+        "type": "Image",
+        "url": "https://lemmy.world/pictrs/image/cdaf8b2c-664a-4ff0-9585-cddc2e50e91a.jpeg"
+    },
+    "commentsEnabled": true,
+    "sensitive": false,
+    "published": "2024-02-14T19:22:33.194766+00:00",
+    "language": {
+        "identifier": "en",
+        "name": "English"
+    },
+    "audience": "https://lemmy.world/c/technology"
+}