commit - 711914363ec34c6669e7f4a1193b8854e5e81ad1
commit + 77918b0001800b96e0bb0aa9dee91d870ab70fca
blob - 7ceb350a035faeef4afb1cfb9156b07ce3f8783f
blob + 005bdb3a278e9e0849e6e6b4267b8c5798f4bff6
--- apub.go
+++ apub.go
const AcceptMediaType string = `application/activity+json; profile="https://www.w3.org/ns/activitystreams"`
-const ToEveryone string = "https://www.w3.org/ns/activitystreams#Public"
+// Activities addressed to this collection indicates the activity
+// is available to all users, authenticated or not.
+// See W3C Recommendation ActivityPub Section 5.6.
+const PublicCollection string = "https://www.w3.org/ns/activitystreams#Public"
var ErrNotExist = errors.New("no such activity")
Name string `json:"name,omitempty"`
Actor string `json:"actor,omitempty"`
Username string `json:"preferredUsername,omitempty"`
- Summary string `json:"summary"`
+ Summary string `json:"summary,omitempty"`
Inbox string `json:"inbox,omitempty"`
Outbox string `json:"outbox,omitempty"`
To []string `json:"to,omitempty"`
CC []string `json:"cc,omitempty"`
+ Followers string `json:"followers,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"`
} `json:"source,omitempty"`
- Audience string `json:"audience,omitempty"`
- Object json.RawMessage `json:"object,omitempty"`
+ PublicKey *PublicKey `json:"publicKey,omitempty"`
+ Audience string `json:"audience,omitempty"`
+ Object json.RawMessage `json:"object,omitempty"`
}
func (act *Activity) UnmarshalJSON(b []byte) error {
Type string `json:"type"`
Name string `json:"name"`
Username string `json:"preferredUsername"`
- Summary string `json:"summary"`
+ Summary string `json:"summary,omitempty"`
Inbox string `json:"inbox"`
Outbox string `json:"outbox"`
+ Followers string `json:"followers"`
Published *time.Time `json:"published,omitempty"`
PublicKey PublicKey `json:"publicKey"`
}
addr := fmt.Sprintf("%s@%s", a.Username, host)
return &mail.Address{a.Name, addr}
}
+
+func (a *Actor) FollowersAddress() *mail.Address {
+ if a.Followers == "" {
+ return &mail.Address{"", ""}
+ }
+ addr := a.Address()
+ user, domain, found := strings.Cut(addr.Address, "@")
+ if !found {
+ return &mail.Address{"", ""}
+ }
+ addr.Address = fmt.Sprintf("%s+followers@%s", user, domain)
+ if addr.Name != "" {
+ addr.Name += " (followers)"
+ }
+ return addr
+}
blob - bb13abcf08f99d617d2ae32bfa06d615320cf921
blob + c262154f5b118c860be68a4b293b7685d9d63e73
--- apub_test.go
+++ apub_test.go
)
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)
+ f, err := os.Open("testdata/announce1.json")
+ if err != nil {
+ t.Fatal(err)
}
+ defer f.Close()
+ a, err := Decode(f)
+ if err != nil {
+ t.Fatal("decode activity", err)
+ }
+ want := "https://lemmy.sdf.org/activities/like/b5bd1577-9677-4130-8312-cd2e2fd4ea44"
+ inner, err := a.Unwrap(nil)
+ if err != nil {
+ t.Fatal("unwrap activity:", err)
+ }
+ if inner.ID != want {
+ t.Errorf("wanted wrapped activity id %s, got %s", want, inner.ID)
+ }
}
blob - 48caa59f42ce6b922cc52b4ea6784ad41c460d70
blob + e5c409b090790dfdb9623c5fe12fad3e4def11e1
--- client.go
+++ client.go
"bytes"
"crypto/rsa"
"encoding/json"
- "errors"
"fmt"
"io"
"net/http"
}
func activityToActor(activity *Activity) *Actor {
- return &Actor{
+ actor := &Actor{
AtContext: activity.AtContext,
ID: activity.ID,
Type: activity.Type,
Username: activity.Username,
Inbox: activity.Inbox,
Outbox: activity.Outbox,
+ Followers: activity.Followers,
Published: activity.Published,
Summary: activity.Summary,
}
+ if activity.PublicKey != nil {
+ actor.PublicKey = *activity.PublicKey
+ }
+ return actor
}
func (c *Client) Send(inbox string, activity *Activity) (*Activity, error) {
return nil, err
}
switch resp.StatusCode {
- case http.StatusOK:
- if resp.ContentLength == 0 {
- return nil, nil
- }
- defer resp.Body.Close()
- activity, err := Decode(resp.Body)
- if errors.Is(err, io.EOF) {
- return nil, nil
- }
- return activity, err
- case http.StatusAccepted, http.StatusNoContent:
+ case http.StatusOK, http.StatusAccepted, http.StatusNoContent:
return nil, nil
case http.StatusNotFound:
return nil, fmt.Errorf("no such inbox %s", inbox)
blob - cea54f93f5b47fca546a5617e3ee9991b12b7876 (mode 644)
blob + /dev/null
--- cmd/listen/create.sql
+++ /dev/null
-CREATE TABLE IF NOT EXISTS actor(
- id TEXT PRIMARY KEY,
- type TEXT NOT NULL,
- name TEXT,
- username TEXT,
- published INTEGER,
- summary TEXT
-);
-
-CREATE TABLE IF NOT EXISTS activity(
- id TEXT PRIMARY KEY,
- type TEXT NOT NULL,
- name TEXT,
- published INTEGER,
- summary TEXT,
- content TEXT,
- attributedTo REFERENCES actor(id),
- inReplyTo INTEGER,
- object INTEGER
-);
-
-CREATE TABLE IF NOT EXISTS recipient_to(
- activity_id REFERENCES activity(id),
- rcpt REFERENCES actor(id)
-);
-
-CREATE TABLE IF NOT EXISTS recipient_cc (
- activity_id REFERENCES activity(id),
- rcpt REFERENCES actor(id)
-);
-
-CREATE VIRTUAL TABLE post USING fts5(
- id, -- AcitivityPub ID
- from,
- to,
- date,
- in_reply_to,
- body
-);
blob - a62fc6064bd6616d917f24b5f4c823c33a5b4905 (mode 644)
blob + /dev/null
--- cmd/listen/db.go
+++ /dev/null
-package main
-
-import (
- "bytes"
- "database/sql"
- "encoding/json"
- "errors"
- "fmt"
- "log"
- "net/http"
- "net/mail"
- "strings"
- "time"
-
- "olowe.co/apub"
-)
-
-func (srv *server) index(activity *apub.Activity) error {
- var who string
- if activity.AttributedTo != "" {
- who = activity.AttributedTo
- } else if activity.Actor != "" {
- who = activity.Actor
- } else {
- return fmt.Errorf("empty actor, empty attributedTo")
- }
- q := "SELECT id FROM actor WHERE id = ?"
- row := srv.db.QueryRow(q, who)
- var id string
- err := row.Scan(&id)
- if errors.Is(err, sql.ErrNoRows) {
- actor, err := apub.LookupActor(who)
- if err != nil {
- return fmt.Errorf("lookup actor %s: %w", activity.AttributedTo, err)
- }
- if err := srv.indexActor(actor); err != nil {
- return fmt.Errorf("index actor %s: %w", actor.ID, err)
- }
- } else if err != nil {
- return fmt.Errorf("query index for actor %s: %w", who, err)
- }
-
- q = "INSERT INTO activity(id, type, name, published, summary, content, attributedTo) VALUES(?, ?, ?, ?, ?, ?, ?)"
- _, err = srv.db.Exec(q, activity.ID, activity.Type, activity.Name, activity.Published.UnixNano(), activity.Summary, activity.Content, activity.AttributedTo)
- if err != nil {
- return err
- }
-
- if len(activity.To) >= 1 {
- recipients := activity.To
- recipients = append(recipients, activity.CC...)
- for _, rcpt := range recipients {
- if rcpt == apub.ToEveryone {
- continue
- }
- q = "INSERT INTO recipient_to VALUES(?, ?)"
- _, err = srv.db.Exec(q, activity.ID, rcpt)
- if err != nil {
- return fmt.Errorf("insert recipient_to: %w", err)
- }
- }
- }
-
- if err := insertFTS(srv.db, activity); err != nil {
- return fmt.Errorf("add to full-text search: %w", err)
- }
- return nil
-}
-
-func (srv *server) indexActor(actor *apub.Actor) error {
- q := "INSERT INTO actor(id, type, name, username, published, summary) VALUES (?, ?, ?, ?, ?, ?)"
- _, err := srv.db.Exec(q, actor.ID, actor.Type, actor.Name, actor.Username, actor.Published.UnixNano(), actor.Summary)
- return err
-}
-
-func insertFTS(db *sql.DB, activity *apub.Activity) error {
- blob, err := apub.MarshalMail(activity)
- if err != nil {
- return fmt.Errorf("marshal activity to text blob: %w", err)
- }
- msg, err := mail.ReadMessage(bytes.NewReader(blob))
- if err != nil {
- return fmt.Errorf("parse intermediate mail message: %w", err)
- }
- q := `INSERT INTO post(id, "from", "to", date, in_reply_to, body) VALUES(?, ?, ?, ?, ?, ?)`
- _, err = db.Exec(q, activity.ID, msg.Header.Get("From"), msg.Header.Get("To"), msg.Header.Get("Date"), msg.Header.Get("In-Reply-To"), blob)
- return err
-}
-
-func (srv *server) handleSearch(w http.ResponseWriter, req *http.Request) {
- if req.Method != http.MethodGet {
- stat := http.StatusMethodNotAllowed
- http.Error(w, http.StatusText(stat), stat)
- return
- }
- query := req.URL.Query().Get("q")
- if query == "" {
- http.Error(w, "empty search query", http.StatusBadRequest)
- return
- } else if len(query) <= 3 {
- http.Error(w, "search query too short: need at least 4 characters", http.StatusBadRequest)
- }
-
- found, err := srv.search(query)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", apub.ContentType)
- if err := json.NewEncoder(w).Encode(found); err != nil {
- log.Printf("encode search results: %v", err)
- }
-}
-
-func (srv *server) search(query string) ([]apub.Activity, error) {
- var stmt string
- if FTS5 {
- stmt = "SELECT id FROM post WHERE post MATCH ? ORDER BY rank"
- } else {
- stmt = "%" + query + "%"
- }
- q := strings.ReplaceAll(query, "@", `"@"`)
- q = strings.ReplaceAll(q, ".", `"."`)
- q = strings.ReplaceAll(q, "/", `"/"`)
- q = strings.ReplaceAll(q, ":", `":"`)
- log.Printf("search %s (escaped %s)", query, q)
- rows, err := srv.db.Query(stmt, q)
- if err != nil {
- return nil, err
- }
- apids := []string{}
- for rows.Next() {
- var s string
- err := rows.Scan(&s)
- if errors.Is(err, sql.ErrNoRows) {
- return []apub.Activity{}, nil
- } else if err != nil {
- return []apub.Activity{}, err
- }
- apids = append(apids, s)
- }
- return srv.lookupActivities(apids)
-}
-
-func (srv *server) lookupActivities(apid []string) ([]apub.Activity, error) {
- q := "SELECT id, type, published, content FROM activity WHERE id " + sqlInExpr(len(apid))
- args := make([]any, len(apid))
- for i := range args {
- args[i] = any(apid[i])
- }
- rows, err := srv.db.Query(q, args...)
- if err != nil {
- return nil, err
- }
-
- activities := []apub.Activity{}
- for rows.Next() {
- var a apub.Activity
- var utime int64
- err := rows.Scan(&a.ID, &a.Type, &utime, &a.Content)
- if errors.Is(err, sql.ErrNoRows) {
- return activities, nil
- } else if err != nil {
- return activities, err
- }
- t := time.Unix(0, utime)
- a.Published = &t
- activities = append(activities, a)
- }
- return activities, rows.Err()
-}
-
-// sqlInExpr returns the equivalent "IN(?, ?, ?, ...)" SQL expression for the given count.
-// This is only intended for use in "SELECT * WHERE id IN (?, ?, ?...)" statements.
-func sqlInExpr(count int) string {
- if count <= 0 {
- return "IN ()"
- }
- return "IN (?" + strings.Repeat(", ?", count-1) + ")"
-}
blob - 9e7a89106283cd14ccbd65dd94f784f6d0d54fd7 (mode 644)
blob + /dev/null
--- cmd/listen/db_test.go
+++ /dev/null
-package main
-
-import "testing"
-
-func TestSelectExpr(t *testing.T) {
- columns := []string{"from", "to", "date", "subject"}
- want := "IN (?, ?, ?, ?)"
- got := sqlInExpr(len(columns))
- if want != got {
- t.Errorf("want %s, got %s", want, got)
- }
-}
blob - e4a92d27ad20b682707738189287196211558fc0 (mode 644)
blob + /dev/null
--- cmd/listen/listen.go
+++ /dev/null
-package main
-
-import (
- "crypto/x509"
- "database/sql"
- "encoding/json"
- "encoding/pem"
- "errors"
- "fmt"
- "io/fs"
- "log"
- "net"
- "net/http"
- "net/smtp"
- "os"
- "path"
- "time"
-
- _ "github.com/mattn/go-sqlite3"
- "olowe.co/apub"
-)
-
-type server struct {
- fsRoot string
- db *sql.DB
- apClient *apub.Client
- relay *smtp.Client
- relayAddr string
-}
-
-func (srv *server) handleReceived(activity *apub.Activity) {
- var err error
- switch activity.Type {
- 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", activity.ID, err)
- return
- }
- srv.handleReceived(wrapped)
- return
- default:
- return
- }
- log.Printf("relaying %s %s to %s", activity.Type, activity.ID, srv.relayAddr)
- err = srv.accept(activity)
- if err != nil {
- log.Printf("relay %s via SMTP: %v", activity.ID, err)
- }
- var netErr *net.OpError
- if errors.As(err, &netErr) {
- srv.relay, err = smtp.Dial(srv.relayAddr)
- if err == nil {
- log.Printf("reconnected to relay %s", srv.relayAddr)
- log.Printf("retrying activity %s", activity.ID)
- srv.handleReceived(activity)
- return
- }
- log.Printf("reconnect to relay %s: %v", srv.relayAddr, 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)
- log.Println(err)
- stat := http.StatusBadRequest
- http.Error(w, err.Error(), stat)
- return
- }
- }
- raddr := req.RemoteAddr
- if req.Header.Get("X-Forwarded-For") != "" {
- raddr = req.Header.Get("X-Forwarded-For")
- }
- if activity.Type != "Like" && activity.Type != "Dislike" {
- log.Printf("%s %s received from %s", activity.Type, activity.ID, raddr)
- }
- switch activity.Type {
- case "Accept", "Reject":
- w.WriteHeader(http.StatusAccepted)
- srv.deliver(activity)
- return
- case "Create", "Note", "Page", "Article":
- w.WriteHeader(http.StatusAccepted)
- srv.handleReceived(activity)
- return
- }
- w.WriteHeader(http.StatusAccepted)
-}
-
-func (srv *server) deliver(a *apub.Activity) error {
- 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)
-}
-
-func (srv *server) accept(a *apub.Activity) error {
- err := apub.SendMail(srv.relay, a, "nobody", "otl")
- if err != nil {
- srv.relay.Quit()
- return fmt.Errorf("relay to SMTP server: %w", err)
- }
- return nil
-}
-
-var home string = os.Getenv("HOME")
-
-const FTS5 bool = true
-
-func logRequest(next http.Handler) http.HandlerFunc {
- return func(w http.ResponseWriter, req *http.Request) {
- // skip logging from checks by load balancer
- if req.URL.Path == "/" && req.Method == http.MethodHead {
- next.ServeHTTP(w, req)
- return
- }
- addr := req.RemoteAddr
- if req.Header.Get("X-Forwarded-For") != "" {
- addr = req.Header.Get("X-Forwarded-For")
- }
- log.Printf("%s %s %s", addr, req.Method, req.URL)
- next.ServeHTTP(w, req)
- }
-}
-
-func newClient(keyPath string, actorPath string) (*apub.Client, error) {
- b, err := os.ReadFile(keyPath)
- if err != nil {
- return nil, fmt.Errorf("load private key: %w", err)
- }
- block, _ := pem.Decode(b)
- key, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
-
- f, err := os.Open(actorPath)
- if err != nil {
- return nil, fmt.Errorf("load actor file: %w", err)
- }
- defer f.Close()
- actor, err := apub.DecodeActor(f)
- if err != nil {
- return nil, fmt.Errorf("decode actor: %w", err)
- }
-
- return &apub.Client{
- Client: http.DefaultClient,
- Key: key,
- Actor: actor,
- }, nil
-}
-
-func serveActorFile(name string) http.HandlerFunc {
- return func(w http.ResponseWriter, req *http.Request) {
- log.Printf("%s checked %s", req.Header.Get("X-Forwarded-For"), name)
- // w.Header().Set("Content-Type", apub.ContentType)
- http.ServeFile(w, req, name)
- }
-}
-
-func main() {
- db, err := sql.Open("sqlite3", path.Join(home, "apubtest/index.db"))
- if err != nil {
- log.Fatal(err)
- }
- if err := db.Ping(); err != nil {
- log.Fatal(err)
- }
-
- raddr := "[::1]:smtp"
- sclient, err := smtp.Dial(raddr)
- if err != nil {
- log.Fatal(err)
- }
- if err := sclient.Noop(); err != nil {
- log.Fatalf("check connection to %s: %v", raddr, err)
- }
-
- srv := &server{
- fsRoot: home + "/apubtest",
- db: db,
- relay: sclient,
- relayAddr: raddr,
- }
- fsys := os.DirFS(srv.fsRoot)
- hfsys := http.FileServer(http.FS(fsys))
- http.HandleFunc("/actor.json", serveActorFile(home+"/apubtest/actor.json"))
- http.HandleFunc("/", logRequest(hfsys))
- http.HandleFunc("/inbox", srv.handleInbox)
- http.HandleFunc("/search", srv.handleSearch)
- log.Fatal(http.ListenAndServe("[::1]:8082", nil))
-}
blob - 8d7bfc91a256cc1d5ab4fde8fb98ed847e397c0e (mode 644)
blob + /dev/null
--- cmd/listen/watch.go
+++ /dev/null
-package main
-
-import (
- "log"
- "time"
-
- "olowe.co/apub/mastodon"
-)
-
-func (srv *server) watch(mastoURL, token string) {
- for {
- stream, err := mastodon.Watch(mastoURL, token)
- if err != nil {
- log.Printf("open mastodon stream: %v", err)
- return
- }
- for stream.Next() {
-
- }
- if stream.Err() != nil {
- log.Printf("read mastodon stream: %v", stream.Err())
- }
- time.Sleep(5)
- }
-}
blob - /dev/null
blob + c335af672b2292bd6c79f985901bca48406cfbb9 (mode 644)
--- /dev/null
+++ cmd/apmail/listen.go
+package main
+
+import (
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log"
+ "net/http"
+ "net/smtp"
+ "os"
+ "os/user"
+ "path"
+ "strings"
+ "time"
+
+ "olowe.co/apub"
+)
+
+type server struct {
+ fsRoot string
+ apClient *apub.Client
+ relayAddr string
+}
+
+func (srv *server) handleReceived(activity *apub.Activity) {
+ var err error
+ switch activity.Type {
+ 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", activity.ID, err)
+ return
+ }
+ srv.handleReceived(wrapped)
+ return
+ default:
+ return
+ }
+ log.Printf("relaying %s %s to %s", activity.Type, activity.ID, srv.relayAddr)
+ err = srv.accept(activity)
+ if err != nil {
+ log.Printf("relay %s via SMTP: %v", 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 {
+ w.Header().Set("Accept", apub.ContentType)
+ w.WriteHeader(http.StatusUnsupportedMediaType)
+ 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)
+ log.Println(err)
+ stat := http.StatusBadRequest
+ http.Error(w, err.Error(), stat)
+ return
+ }
+ }
+ raddr := req.RemoteAddr
+ if req.Header.Get("X-Forwarded-For") != "" {
+ raddr = req.Header.Get("X-Forwarded-For")
+ }
+ if activity.Type != "Like" && activity.Type != "Dislike" {
+ log.Printf("%s %s received from %s", activity.Type, activity.ID, raddr)
+ }
+ switch activity.Type {
+ case "Accept", "Reject":
+ w.WriteHeader(http.StatusAccepted)
+ srv.deliver(activity)
+ return
+ case "Create", "Note", "Page", "Article":
+ w.WriteHeader(http.StatusAccepted)
+ srv.handleReceived(activity)
+ return
+ }
+ w.WriteHeader(http.StatusAccepted)
+}
+
+func (srv *server) deliver(a *apub.Activity) error {
+ 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)
+}
+
+func (srv *server) accept(a *apub.Activity) error {
+ return apub.SendMail(srv.relayAddr, nil, "nobody", []string{"otl"}, a)
+}
+
+var home string = os.Getenv("HOME")
+
+func logRequest(next http.Handler) http.HandlerFunc {
+ return func(w http.ResponseWriter, req *http.Request) {
+ // skip logging from checks by load balancer
+ if req.URL.Path == "/" && req.Method == http.MethodHead {
+ next.ServeHTTP(w, req)
+ return
+ }
+ addr := req.RemoteAddr
+ if req.Header.Get("X-Forwarded-For") != "" {
+ addr = req.Header.Get("X-Forwarded-For")
+ }
+ log.Printf("%s %s %s", addr, req.Method, req.URL)
+ next.ServeHTTP(w, req)
+ }
+}
+
+func newClient(keyPath string, actorPath string) (*apub.Client, error) {
+ b, err := os.ReadFile(keyPath)
+ if err != nil {
+ return nil, fmt.Errorf("load private key: %w", err)
+ }
+ block, _ := pem.Decode(b)
+ key, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
+
+ f, err := os.Open(actorPath)
+ if err != nil {
+ return nil, fmt.Errorf("load actor file: %w", err)
+ }
+ defer f.Close()
+ actor, err := apub.DecodeActor(f)
+ if err != nil {
+ return nil, fmt.Errorf("decode actor: %w", err)
+ }
+
+ return &apub.Client{
+ Client: http.DefaultClient,
+ Key: key,
+ Actor: actor,
+ }, nil
+}
+
+func serveActorFile(name string) http.HandlerFunc {
+ return func(w http.ResponseWriter, req *http.Request) {
+ log.Printf("%s checked %s", req.Header.Get("X-Forwarded-For"), name)
+ w.Header().Set("Content-Type", apub.ContentType)
+ http.ServeFile(w, req, name)
+ }
+}
+
+func serveActivityFile(hfsys http.Handler) http.HandlerFunc {
+ return func(w http.ResponseWriter, req *http.Request) {
+ w.Header().Set("Content-Type", apub.ContentType)
+ hfsys.ServeHTTP(w, req)
+ }
+}
+
+func serveWebFingerFile(w http.ResponseWriter, req *http.Request) {
+ if !req.URL.Query().Has("resource") {
+ http.Error(w, "missing resource query parameter", http.StatusBadRequest)
+ return
+ }
+ q := req.URL.Query().Get("resource")
+ if !strings.HasPrefix(q, "acct:") {
+ http.Error(w, "only acct resource lookup supported", http.StatusNotImplemented)
+ return
+ }
+ addr := strings.TrimPrefix(q, "acct:")
+ username, _, ok := strings.Cut(addr, "@")
+ if !ok {
+ http.Error(w, "bad acct lookup: missing @ in address", http.StatusBadRequest)
+ return
+ }
+ fname, err := apub.UserWebFingerFile(username)
+ if _, ok := err.(user.UnknownUserError); ok {
+ http.Error(w, "no such user", http.StatusNotFound)
+ return
+ } else if err != nil {
+ log.Println(err)
+ http.Error(w, "oops", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ http.ServeFile(w, req, fname)
+}
+
+const usage string = "usage: apmail [address]"
+
+func main() {
+ if len(os.Args) > 2 {
+ log.Fatal(usage)
+ }
+ raddr := "[::1]:smtp"
+ if len(os.Args) == 2 {
+ raddr = os.Args[1]
+ }
+ sclient, err := smtp.Dial(raddr)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := sclient.Noop(); err != nil {
+ log.Fatalf("check connection to %s: %v", raddr, err)
+ }
+ sclient.Quit()
+ sclient.Close()
+
+ srv := &server{
+ fsRoot: home + "/apubtest",
+ relayAddr: raddr,
+ }
+ fsys := os.DirFS(srv.fsRoot)
+ hfsys := http.FileServer(http.FS(fsys))
+ http.HandleFunc("/actor.json", serveActorFile(home+"/apubtest/actor.json"))
+ http.HandleFunc("/.well-known/webfinger", serveWebFingerFile)
+ http.Handle("/", hfsys)
+ http.HandleFunc("/outbox/", serveActivityFile(hfsys))
+ http.HandleFunc("/inbox", srv.handleInbox)
+ log.Fatal(http.ListenAndServe("[::1]:8082", nil))
+}
blob - 88354596aa7adde3b46dd2768e453b776bb3c78e
blob + 66e602e1e4f79e3d20d5d9834585ff5fbe991b32
--- go.mod
+++ go.mod
go 1.19
-require github.com/mattn/go-sqlite3 v1.14.22 // indirect
+require (
+ github.com/emersion/go-smtp v0.20.2
+ webfinger.net/go/webfinger v0.1.0
+)
+
+require github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
blob - e8d092a96f051136dfa34966e255d3cedd866349
blob + a139f20def695ecfe9371f5f60ac72f4b7b3dd19
--- go.sum
+++ go.sum
-github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
-github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
+github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
+github.com/emersion/go-smtp v0.20.2 h1:peX42Qnh5Q0q3vrAnRy43R/JwTnnv75AebxbkTL7Ia4=
+github.com/emersion/go-smtp v0.20.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
+webfinger.net/go/webfinger v0.1.0 h1:e/J18UgjFE8+ZbKxzKm4+gv4ehidNnF6hcbHwS3K63U=
+webfinger.net/go/webfinger v0.1.0/go.mod h1:+najbdnIKfnKo68tU2TF+AXm8/MOqLYXqx22j8Xw7FM=
blob - 93e9b8a3bd6c3959154b7db16013ede15d00769f
blob + afc05ba205700e52300473ed8e2a3a1b3ed6faef
--- mail.go
+++ mail.go
import (
"bytes"
"fmt"
+ "io"
"net/mail"
"net/smtp"
"strings"
func MarshalMail(activity *Activity) ([]byte, error) {
buf := &bytes.Buffer{}
- actor, err := LookupActor(activity.AttributedTo)
+ from, 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())
+ fmt.Fprintf(buf, "From: %s\n", from.Address())
+ var rcpt []string
+ for _, u := range activity.To {
+ if u == PublicCollection {
+ continue
+ }
+ actor, err := LookupActor(u)
+ if err != nil {
+ return nil, fmt.Errorf("lookup actor %s: %w", u, err)
+ }
+ rcpt = append(rcpt, actor.Address().String())
+ }
+ fmt.Fprintln(buf, "To:", strings.Join(rcpt, ", "))
+
+ var rcptcc []string
if activity.CC != nil {
- buf.WriteString("To: ")
- rcpt := append(activity.To, activity.CC...)
- var addrs []string
- for _, u := range rcpt {
- if u == ToEveryone {
+ for _, u := range activity.CC {
+ if u == PublicCollection {
continue
+ } else if u == from.Followers {
+ rcptcc = append(rcptcc, from.FollowersAddress().String())
+ continue
}
- actor, err = LookupActor(u)
+ actor, err := LookupActor(u)
if err != nil {
return nil, fmt.Errorf("lookup actor %s: %w", u, err)
}
- addrs = append(addrs, actor.Address().String())
+ rcptcc = append(rcptcc, actor.Address().String())
}
- buf.WriteString(strings.Join(addrs, ", "))
- buf.WriteString("\n")
+ fmt.Fprintln(buf, "CC:", strings.Join(rcptcc, ", "))
}
fmt.Fprintf(buf, "Date: %s\n", activity.Published.Format(time.RFC822))
if activity.Source.Content != "" && activity.Source.MediaType == "text/markdown" {
fmt.Fprintln(buf, "Content-Type: text/plain; charset=utf-8")
- } else {
+ } else if activity.MediaType != "" {
fmt.Fprintln(buf, "Content-Type:", activity.MediaType)
+ } else {
+ fmt.Fprintln(buf, "Content-Type:", "text/html; charset=utf-8")
}
fmt.Fprintln(buf, "Subject:", activity.Name)
fmt.Fprintln(buf)
return buf.Bytes(), err
}
-func SendMail(client *smtp.Client, activity *Activity, from string, to ...string) error {
- b, err := MarshalMail(activity)
+func UnmarshalMail(msg *mail.Message) (*Activity, error) {
+ date, err := msg.Header.Date()
if err != nil {
- return fmt.Errorf("marshal to mail message: %w", err)
+ return nil, fmt.Errorf("parse message date: %w", err)
}
- if err := client.Mail(from); err != nil {
- return fmt.Errorf("mail command: %w", err)
+ from, err := msg.Header.AddressList("From")
+ if err != nil {
+ return nil, fmt.Errorf("parse From: %w", err)
}
- for _, rcpt := range to {
- if err := client.Rcpt(rcpt); err != nil {
- return fmt.Errorf("rcpt command: %w", err)
- }
+ wfrom, err := Finger(from[0].Address)
+ if err != nil {
+ return nil, fmt.Errorf("webfinger From: %w", err)
}
- wc, err := client.Data()
+
+ to, err := msg.Header.AddressList("To")
if err != nil {
- return fmt.Errorf("data command: %w", err)
+ return nil, fmt.Errorf("parse To address list: %w", err)
}
- if _, err := wc.Write(b); err != nil {
- return fmt.Errorf("write message: %w", err)
+ wto, err := fingerAll(to)
+ if err != nil {
+ return nil, fmt.Errorf("webfinger To addresses: %w", err)
}
- if err := wc.Close(); err != nil {
- return fmt.Errorf("close message writer: %w", err)
+ var wcc []string
+ if msg.Header.Get("CC") != "" {
+ cc, err := msg.Header.AddressList("CC")
+ if err != nil {
+ return nil, fmt.Errorf("parse CC address list: %w", err)
+ }
+ wcc, err = fingerAll(cc)
+ if err != nil {
+ return nil, fmt.Errorf("webfinger CC addresses: %w", err)
+ }
}
- return nil
+
+ buf := &bytes.Buffer{}
+ if _, err := io.Copy(buf, msg.Body); err != nil {
+ return nil, fmt.Errorf("read message body: %v", err)
+ }
+
+ return &Activity{
+ AtContext: AtContext,
+ Type: "Note",
+ AttributedTo: wfrom.ID,
+ To: wto,
+ CC: wcc,
+ MediaType: "text/markdown",
+ Name: strings.TrimSpace(msg.Header.Get("Subject")),
+ Content: strings.TrimSpace(buf.String()),
+ InReplyTo: strings.Trim(msg.Header.Get("In-Reply-To"), "<>"),
+ Published: &date,
+ }, nil
}
+
+func SendMail(addr string, auth smtp.Auth, from string, to []string, activity *Activity) error {
+ msg, err := MarshalMail(activity)
+ if err != nil {
+ return fmt.Errorf("marshal to mail message: %w", err)
+ }
+ return smtp.SendMail(addr, auth, from, to, msg)
+}
blob - 874d883c4e3b114252008c510be26e62bec5bd87
blob + 719f8db1b08a80ce3600fde0a0a856af9267803f
--- mail_test.go
+++ mail_test.go
import (
"bytes"
- "net"
"net/mail"
- "net/smtp"
"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)
+func TestMailAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ from string
+ followers string
+ }{
+ {
+ "testdata/actor/mastodon.json",
+ `"Oliver Lowe" <otl@hachyderm.io>`,
+ `"Oliver Lowe (followers)" <otl+followers@hachyderm.io>`,
+ },
+ {
+ "testdata/actor/akkoma.json",
+ `"Kari'boka" <kariboka@social.harpia.red>`,
+ `"Kari'boka (followers)" <kariboka+followers@social.harpia.red>`,
+ },
+ {
+ "testdata/actor/lemmy.json",
+ "<Spotlight7573@lemmy.world>",
+ "<@>", // empty mail.Address
+ },
}
- if actor.Address().String() != want {
- t.Errorf("got %s, want %s", actor.Address().String(), want)
+ for _, tt := range tests {
+ f, err := os.Open(tt.name)
+ if err != nil {
+ t.Error(err)
+ continue
+ }
+ defer f.Close()
+ actor, err := DecodeActor(f)
+ if err != nil {
+ t.Errorf("%s: decode actor: %v", tt.name, err)
+ continue
+ }
+ if actor.Address().String() != tt.from {
+ t.Errorf("%s: from address: want %s, got %s", tt.name, tt.from, actor.Address().String())
+ }
+ got := actor.FollowersAddress().String()
+ if got != tt.followers {
+ t.Errorf("%s: followers address: want %s, got %s", tt.name, tt.followers, got)
+ }
}
+}
- f, err := os.Open("testdata/note.json")
- if err != nil {
- t.Fatal(err)
+func TestMarshalMail(t *testing.T) {
+ var notes []string = []string{
+ "testdata/note/akkoma.json",
+ "testdata/note/lemmy.json",
+ "testdata/note/mastodon.json",
+ "testdata/page.json",
}
- defer f.Close()
- activity, err := Decode(f)
- if err != nil {
- t.Fatal(err)
+ for _, name := range notes {
+ f, err := os.Open(name)
+ if err != nil {
+ t.Error(err)
+ continue
+ }
+ defer f.Close()
+ a, err := Decode(f)
+ if err != nil {
+ t.Errorf("%s: decode activity: %v", name, err)
+ continue
+ }
+ b, err := MarshalMail(a)
+ if err != nil {
+ t.Errorf("%s: marshal to mail message: %v", name, err)
+ continue
+ }
+ if _, err := mail.ReadMessage(bytes.NewReader(b)); err != nil {
+ t.Errorf("%s: read back message from marshalled activity: %v", name, 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)
- }
}
-func TestSendMail(t *testing.T) {
- f, err := os.Open("testdata/note.json")
+func TestUnmarshalMail(t *testing.T) {
+ f, err := os.Open("testdata/outbound.eml")
if err != nil {
t.Fatal(err)
}
- a, err := Decode(f)
+ defer f.Close()
+ msg, err := mail.ReadMessage(f)
if err != nil {
t.Fatal(err)
}
- f.Close()
-
- conn, err := net.Dial("tcp", "[::1]:smtp")
- if err != nil {
+ if testing.Short() {
+ t.Skip("skipping network calls to unmarshal mail message to Activity")
+ }
+ if _, err := UnmarshalMail(msg); err != nil {
t.Fatal(err)
}
- client, err := smtp.NewClient(conn, "localhost")
- err = SendMail(client, a, "test@example.invalid", "otl")
- if err != nil {
- t.Error(err)
- }
- client.Quit()
}
blob - 884413133471fb99cba255807cf494132b0a8bdc
blob + d2b48ada96c8e1abfc425d1ce217df57ccfe1544
--- sign_test.go
+++ sign_test.go
"crypto/rsa"
"crypto/x509"
"encoding/pem"
- "fmt"
"net/http"
"os"
"strings"
if err := Sign(req, key, "http://from.invalid/actor"); err != nil {
t.Fatal(err)
}
- fmt.Println(req.Header.Get("Digest"))
- fmt.Println(req.Header.Get("Signature"))
}
blob - 6c5a6e55945fadfc2144d0dca75d21a0c23c1f45 (mode 644)
blob + /dev/null
--- testdata/actor.json
+++ /dev/null
-{
- "@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'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 + 8faa0f2382e2c9b81ad25af93ae4aafde89c4b9f (mode 644)
--- /dev/null
+++ testdata/actor/akkoma.json
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://social.harpia.red/schemas/litepub-0.1.jsonld",
+ {
+ "@language": "und"
+ }
+ ],
+ "alsoKnownAs": [
+ "https://ursal.zone/users/kariboka"
+ ],
+ "attachment": [
+ {
+ "name": "Pro(nome/nombre/noun)",
+ "type": "PropertyValue",
+ "value": "Ele/\u00c9l/He"
+ },
+ {
+ "name": "Libre.fm",
+ "type": "PropertyValue",
+ "value": "<a href=\"http://libre.fm/user/kariboka\" rel=\"ugc\">libre.fm/user/kariboka</a>"
+ },
+ {
+ "name": "Cover",
+ "type": "PropertyValue",
+ "value": "PLA training Tibetan Women"
+ }
+ ],
+ "capabilities": {},
+ "discoverable": false,
+ "endpoints": {
+ "oauthAuthorizationEndpoint": "https://social.harpia.red/oauth/authorize",
+ "oauthRegistrationEndpoint": "https://social.harpia.red/api/v1/apps",
+ "oauthTokenEndpoint": "https://social.harpia.red/oauth/token",
+ "sharedInbox": "https://social.harpia.red/inbox",
+ "uploadMedia": "https://social.harpia.red/api/ap/upload_media"
+ },
+ "featured": "https://social.harpia.red/users/kariboka/collections/featured",
+ "followers": "https://social.harpia.red/users/kariboka/followers",
+ "following": "https://social.harpia.red/users/kariboka/following",
+ "icon": {
+ "type": "Image",
+ "url": "https://social.harpia.red/media/1c7eaca3442e4c382015fd9dc0d66f8d13752046cce9e7524d0027a31e276c91.png"
+ },
+ "id": "https://social.harpia.red/users/kariboka",
+ "image": {
+ "type": "Image",
+ "url": "https://social.harpia.red/media/57b445702afdb890b97c1479445c8a4fd2e090e9e9efd64082ae768339e6be40.png"
+ },
+ "inbox": "https://social.harpia.red/users/kariboka/inbox",
+ "manuallyApprovesFollowers": false,
+ "name": "Kari'boka",
+ "outbox": "https://social.harpia.red/users/kariboka/outbox",
+ "preferredUsername": "kariboka",
+ "publicKey": {
+ "id": "https://social.harpia.red/users/kariboka#main-key",
+ "owner": "https://social.harpia.red/users/kariboka",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsq3M+ZI7P0Y40bjZo+PT\n1a3uakmF4YVBzBOTgJx3OelGujlgV49T/N7yTTgWoBOQAzrDYVMz206cPUTUSGLh\nNOzq1c1S8XQfY7bH/VaggKWySMeK8UWqMiLYgtwyaPfieEqPnFO4Yoa6X5VxgkRN\nwmR27rcGq1YIBVLq/b52CcK+/cPkezYYg+4xyBjysusJjb3VlqPLX6quT+zVs/Nc\now3Mpg7vaP+/Z+3wxOZp1X2P42v5w4ggysPpUfjHyy4h+8N8h752r7XO1s2zowY7\npPU9jexeaTAH4dAYjSwLcluboL0gDFQiG7wAqRoVxYHYOE4IF8k9VSKBfB/QjeGe\nhwIDAQAB\n-----END PUBLIC KEY-----\n\n"
+ },
+ "summary": "Trabalhadores e oprimidos, unam-se!<br/><br/>"\u00bfC\u00f3mo van a silenciar al jilguero o al canario?<br/>Si no hay c\u00e1rcel ni tumba para el canto libertario"<br/><br/><a class=\"hashtag\" data-tag=\"communism\" href=\"https://social.harpia.red/tag/communism\">#Communism</a> <a class=\"hashtag\" data-tag=\"marxismleninism\" href=\"https://social.harpia.red/tag/marxismleninism\">#MarxismLeninism</a> <a class=\"hashtag\" data-tag=\"foss\" href=\"https://social.harpia.red/tag/foss\">#FOSS</a> <a class=\"hashtag\" data-tag=\"metal\" href=\"https://social.harpia.red/tag/metal\">#Metal</a> <a class=\"hashtag\" data-tag=\"piracy\" href=\"https://social.harpia.red/tag/piracy\">#Piracy</a> <a class=\"hashtag\" data-tag=\"antifa\" href=\"https://social.harpia.red/tag/antifa\">#Antifa</a> <a class=\"hashtag\" data-tag=\"freepalestine\" href=\"https://social.harpia.red/tag/freepalestine\">#FreePalestine</a> <a class=\"hashtag\" data-tag=\"fedi22\" href=\"https://social.harpia.red/tag/fedi22\">#Fedi22</a> <br/><br/>\ud83c\uddf5\ud83c\uddf8 \ud83c\udde8\ud83c\uddfa \ud83c\uddf0\ud83c\uddf5 \ud83c\uddfb\ud83c\uddf3 \ud83c\udde6\ud83c\uddf4 \ud83c\uddf1\ud83c\udde6 \ud83c\udde8\ud83c\uddf3 \ud83c\uddf2\ud83c\uddff \ud83c\udde6\ud83c\uddf1 \ud83c\uddee\ud83c\uddf6<br/>\ud83c\udde7\ud83c\uddf7 \ud83c\uddfa\ud83c\uddfe \ud83c\udde6\ud83c\uddf7 \ud83c\udde8\ud83c\uddf4 \ud83c\uddf5\ud83c\uddfe \ud83c\uddf5\ud83c\uddea \ud83c\udde8\ud83c\uddf1 \ud83c\udde7\ud83c\uddf4 \ud83c\uddfb\ud83c\uddea \ud83c\uddec\ud83c\uddfe<br/><br/>\ud83c\udff3\ufe0f\u200d\ud83c\udf08 \ud83c\udff3\ufe0f\u200d\u26a7\ufe0f \ud83c\udff4\u200d\u2620\ufe0f<br/><br/>:com: :ana: :afa: :bp: :tux: :foss: :fedi: ",
+ "tag": [
+ {
+ "icon": {
+ "type": "Image",
+ "url": "https://social.harpia.red/emoji/revol/antifa.png"
+ },
+ "id": "https://social.harpia.red/emoji/revol/antifa.png",
+ "name": ":afa:",
+ "type": "Emoji",
+ "updated": "1970-01-01T00:00:00Z"
+ },
+ {
+ "icon": {
+ "type": "Image",
+ "url": "https://social.harpia.red/emoji/revol/anarchism.png"
+ },
+ "id": "https://social.harpia.red/emoji/revol/anarchism.png",
+ "name": ":ana:",
+ "type": "Emoji",
+ "updated": "1970-01-01T00:00:00Z"
+ },
+ {
+ "icon": {
+ "type": "Image",
+ "url": "https://social.harpia.red/emoji/revol/bp.png"
+ },
+ "id": "https://social.harpia.red/emoji/revol/bp.png",
+ "name": ":bp:",
+ "type": "Emoji",
+ "updated": "1970-01-01T00:00:00Z"
+ },
+ {
+ "icon": {
+ "type": "Image",
+ "url": "https://social.harpia.red/emoji/revol/communism.png"
+ },
+ "id": "https://social.harpia.red/emoji/revol/communism.png",
+ "name": ":com:",
+ "type": "Emoji",
+ "updated": "1970-01-01T00:00:00Z"
+ },
+ {
+ "icon": {
+ "type": "Image",
+ "url": "https://social.harpia.red/emoji/revol/fediverse.png"
+ },
+ "id": "https://social.harpia.red/emoji/revol/fediverse.png",
+ "name": ":fedi:",
+ "type": "Emoji",
+ "updated": "1970-01-01T00:00:00Z"
+ },
+ {
+ "icon": {
+ "type": "Image",
+ "url": "https://social.harpia.red/emoji/revol/foss.png"
+ },
+ "id": "https://social.harpia.red/emoji/revol/foss.png",
+ "name": ":foss:",
+ "type": "Emoji",
+ "updated": "1970-01-01T00:00:00Z"
+ },
+ {
+ "icon": {
+ "type": "Image",
+ "url": "https://social.harpia.red/emoji/revol/tux.png"
+ },
+ "id": "https://social.harpia.red/emoji/revol/tux.png",
+ "name": ":tux:",
+ "type": "Emoji",
+ "updated": "1970-01-01T00:00:00Z"
+ }
+ ],
+ "type": "Person",
+ "url": "https://social.harpia.red/users/kariboka"
+}
blob - /dev/null
blob + f69c438bdd3524700138e1d52dab9f705d1625ae (mode 644)
--- /dev/null
+++ testdata/actor/lemmy.json
+{
+ "@context": [
+ "https://join-lemmy.org/context.json",
+ "https://www.w3.org/ns/activitystreams"
+ ],
+ "type": "Person",
+ "id": "https://lemmy.world/u/Spotlight7573",
+ "preferredUsername": "Spotlight7573",
+ "inbox": "https://lemmy.world/u/Spotlight7573/inbox",
+ "outbox": "https://lemmy.world/u/Spotlight7573/outbox",
+ "publicKey": {
+ "id": "https://lemmy.world/u/Spotlight7573#main-key",
+ "owner": "https://lemmy.world/u/Spotlight7573",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArnasafHuj2hkBYosS4LG\n28K6H9W+Z9iiMfX6J44VEt/x8CpA2tIWsGpWOk4ur+8Wt7lPqhH84v7fpQwseFl3\nOGAe0k0wiSN+doF8KuqPv6mkpYTVT+8u+9+hafN+mA2DLrKmxxJX3V8CEJMxPvF8\nUI+mPpl1QgXb6DOnAzwD3LPXDqJRlI8y+2tE5POJYWbRdWgdJjHwuxi/ZJ4uNgsL\nUrHdPI5UU+CprXL3jl3cWzr8L9rvG0a7WFyEk1BpfHzIfXtOv9dwSbcTFFYvp84q\nsE6T2ND/jb0S+ALKkFYGf0LVSfQI8a3bmufC08wLxs1UqS9tZhsYFFA6AJj9yAGS\nZwIDAQAB\n-----END PUBLIC KEY-----\n"
+ },
+ "endpoints": {
+ "sharedInbox": "https://lemmy.world/inbox"
+ },
+ "published": "2023-06-15T15:51:37.355579Z"
+}
blob - /dev/null
blob + 6c5a6e55945fadfc2144d0dca75d21a0c23c1f45 (mode 644)
--- /dev/null
+++ testdata/actor/mastodon.json
+{
+ "@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'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 - 6aea9443a4186947f42ae983f26dbe41b317c9a3 (mode 644)
blob + /dev/null
--- testdata/note.json
+++ /dev/null
-{
- "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 + 0c1d3cb40f4a1d5b00ace8889e403233e1cb55cd (mode 644)
--- /dev/null
+++ testdata/note/akkoma.json
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://social.harpia.red/schemas/litepub-0.1.jsonld",
+ {
+ "@language": "und"
+ }
+ ],
+ "actor": "https://social.harpia.red/users/kariboka",
+ "attachment": [],
+ "attributedTo": "https://social.harpia.red/users/kariboka",
+ "cc": [
+ "https://social.harpia.red/users/kariboka/followers"
+ ],
+ "content": "<p><span class=\"h-card\"><a class=\"u-url mention\" data-user=\"Afa6lBDBQ2IWEkCOem\" href=\"https://apubtest2.srcbeat.com/actor.json\" rel=\"ugc\">@<span>otl</span></a></span> hello from akkoma!</p>",
+ "contentMap": {
+ "pt": "<p><span class=\"h-card\"><a class=\"u-url mention\" data-user=\"Afa6lBDBQ2IWEkCOem\" href=\"https://apubtest2.srcbeat.com/actor.json\" rel=\"ugc\">@<span>otl</span></a></span> hello from akkoma!</p>"
+ },
+ "context": "https://apubtest2.srcbeat.com/outbox/1709705294468500457",
+ "conversation": "https://apubtest2.srcbeat.com/outbox/1709705294468500457",
+ "id": "https://social.harpia.red/objects/8db74fa7-d2fe-496c-b3f1-d93227c94bb9",
+ "inReplyTo": "https://apubtest2.srcbeat.com/outbox/1709705294468500457",
+ "published": "2024-03-06T17:11:06.113580Z",
+ "sensitive": false,
+ "source": {
+ "content": "@otl@apubtest2.srcbeat.com hello from akkoma!",
+ "mediaType": "text/markdown"
+ },
+ "summary": "",
+ "tag": [
+ {
+ "href": "https://apubtest2.srcbeat.com/actor.json",
+ "name": "@otl@apubtest2.srcbeat.com",
+ "type": "Mention"
+ }
+ ],
+ "to": [
+ "https://apubtest2.srcbeat.com/actor.json",
+ "https://www.w3.org/ns/activitystreams#Public"
+ ],
+ "type": "Note"
+}
blob - /dev/null
blob + 6aea9443a4186947f42ae983f26dbe41b317c9a3 (mode 644)
--- /dev/null
+++ testdata/note/lemmy.json
+{
+ "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 + f2ab0653599e1109aa55d3fdb4aad35b244aa189 (mode 644)
--- /dev/null
+++ testdata/note/mastodon.json
+{
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ {
+ "ostatus": "http://ostatus.org#",
+ "atomUri": "ostatus:atomUri",
+ "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
+ "conversation": "ostatus:conversation",
+ "sensitive": "as:sensitive",
+ "toot": "http://joinmastodon.org/ns#",
+ "votersCount": "toot:votersCount",
+ "blurhash": "toot:blurhash",
+ "focalPoint": {
+ "@container": "@list",
+ "@id": "toot:focalPoint"
+ },
+ "Hashtag": "as:Hashtag"
+ }
+ ],
+ "id": "https://hachyderm.io/users/otl/statuses/111994854113576914",
+ "type": "Note",
+ "summary": null,
+ "inReplyTo": null,
+ "published": "2024-02-26T00:04:15Z",
+ "url": "https://hachyderm.io/@otl/111994854113576914",
+ "attributedTo": "https://hachyderm.io/users/otl",
+ "to": [
+ "https://www.w3.org/ns/activitystreams#Public"
+ ],
+ "cc": [
+ "https://hachyderm.io/users/otl/followers",
+ "https://lemmy.world/c/selfhosted",
+ "https://lemmy.world/c/selfhosted/followers"
+ ],
+ "sensitive": false,
+ "atomUri": "https://hachyderm.io/users/otl/statuses/111994854113576914",
+ "inReplyToAtomUri": null,
+ "conversation": "tag:hachyderm.io,2024-02-26:objectId=129729049:objectType=Conversation",
+ "content": "<p>Follow-up: OpenBSD routers on AliExpress mini PCs</p><p>I got lots of replies to the last post showing the little OpenBSD internet gateway setup (super interesting; thanks!). Here's more info and pictures:<br /><a href=\"https://www.srcbeat.com/2024/02/aliexpress-openbsd-router/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\" translate=\"no\"><span class=\"invisible\">https://www.</span><span class=\"ellipsis\">srcbeat.com/2024/02/aliexpress</span><span class=\"invisible\">-openbsd-router/</span></a></p><p>Something I've been meaning to share for years now.</p><p><span class=\"h-card\" translate=\"no\"><a href=\"https://lemmy.world/c/selfhosted\" class=\"u-url mention\">@<span>selfhosted</span></a></span> <a href=\"https://hachyderm.io/tags/openbsd\" class=\"mention hashtag\" rel=\"tag\">#<span>openbsd</span></a> <a href=\"https://hachyderm.io/tags/selfhosted\" class=\"mention hashtag\" rel=\"tag\">#<span>selfhosted</span></a> <a href=\"https://hachyderm.io/tags/selfhosting\" class=\"mention hashtag\" rel=\"tag\">#<span>selfhosting</span></a></p>",
+ "contentMap": {
+ "en": "<p>Follow-up: OpenBSD routers on AliExpress mini PCs</p><p>I got lots of replies to the last post showing the little OpenBSD internet gateway setup (super interesting; thanks!). Here's more info and pictures:<br /><a href=\"https://www.srcbeat.com/2024/02/aliexpress-openbsd-router/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\" translate=\"no\"><span class=\"invisible\">https://www.</span><span class=\"ellipsis\">srcbeat.com/2024/02/aliexpress</span><span class=\"invisible\">-openbsd-router/</span></a></p><p>Something I've been meaning to share for years now.</p><p><span class=\"h-card\" translate=\"no\"><a href=\"https://lemmy.world/c/selfhosted\" class=\"u-url mention\">@<span>selfhosted</span></a></span> <a href=\"https://hachyderm.io/tags/openbsd\" class=\"mention hashtag\" rel=\"tag\">#<span>openbsd</span></a> <a href=\"https://hachyderm.io/tags/selfhosted\" class=\"mention hashtag\" rel=\"tag\">#<span>selfhosted</span></a> <a href=\"https://hachyderm.io/tags/selfhosting\" class=\"mention hashtag\" rel=\"tag\">#<span>selfhosting</span></a></p>"
+ },
+ "attachment": [
+ {
+ "type": "Document",
+ "mediaType": "image/jpeg",
+ "url": "https://media.hachyderm.io/media_attachments/files/111/994/844/539/887/387/original/1aeda4458378f609.jpeg",
+ "name": "Holding a mini PC and network appliance from AliExpress showing its 4 ethernet ports",
+ "blurhash": "URGbL@%g$%RiM}E2xajE~VR.RkWVjEt6ozt6",
+ "focalPoint": [
+ 0.0,
+ 0.0
+ ],
+ "width": 1111,
+ "height": 791
+ }
+ ],
+ "tag": [
+ {
+ "type": "Mention",
+ "href": "https://lemmy.world/c/selfhosted",
+ "name": "@selfhosted@lemmy.world"
+ },
+ {
+ "type": "Hashtag",
+ "href": "https://hachyderm.io/tags/openbsd",
+ "name": "#openbsd"
+ },
+ {
+ "type": "Hashtag",
+ "href": "https://hachyderm.io/tags/selfhosted",
+ "name": "#selfhosted"
+ },
+ {
+ "type": "Hashtag",
+ "href": "https://hachyderm.io/tags/selfhosting",
+ "name": "#selfhosting"
+ }
+ ],
+ "replies": {
+ "id": "https://hachyderm.io/users/otl/statuses/111994854113576914/replies",
+ "type": "Collection",
+ "first": {
+ "type": "CollectionPage",
+ "next": "https://hachyderm.io/users/otl/statuses/111994854113576914/replies?only_other_accounts=true&page=true",
+ "partOf": "https://hachyderm.io/users/otl/statuses/111994854113576914/replies",
+ "items": []
+ }
+ }
+}
blob - /dev/null
blob + 16154099f40f206a7ceb3e7f4bc6645e68288f8b (mode 644)
--- /dev/null
+++ testdata/outbound.eml
+From: Oliver Lowe <otl@apubtest2.srcbeat.com>
+Date: Wed, 6 Mar 2024 14:03:33 +1100
+To: <henfredemars@infosec.pub>
+CC: "Programming" <programming@programming.dev>, <starman@programming.dev>
+In-Reply-To: <https://infosec.pub/comment/7135766>
+Subject:
+
+> It's a nice thought, but the White House encouraging
+> memory safety seems like a relatively insignificant push. It's the
+> weight of legacy code and established solutions that will hold us back
+> for a long time.
+
+Absolutely. Memory-safe languages have been around for decades. The
+reason there is so much poor code - including ones with manual memory
+management bugs - out there is not a technical problem. There are
+hordes and hordes of programmers, managers, companies etc. who would
+love to get paid to port this stuff. They'll do it for 10% of the
+price those stupid lumbering tech consultancies do it for.
+
+But who gets the contracts in the end?
+Give me a f'ing break!
blob - /dev/null
blob + be4d275335ef7ae206c3f1798b71ea42e15e545f (mode 644)
--- /dev/null
+++ testdata/private.pem
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEA0uol3b0GtoECcWa5ogBjLCoBjwjuG2MqM2+Hn5n05U36yWE7
+Pb0L8t9Ifx/ulTdOQUDlnEJWtqFTL+PwqG4cCJMMy3m0cOEbhN5HwWHAFgbwjlQc
+Dj31XToAZisiUtI80uT75ckq0NvJcitXyNzdXXQTQ0nKCH1Meok4MWI7nyGnceTa
+E3Mzh178v0xl9BkNSfRmqG8Lxw+mp8mqS0jIvLIGle9HTNeKMkISlV/4JgQqx5q9
+0Zy7sJaOxKoeyw3blPdYyb90b90zPhPlASNdK+gZ695kygQgLapFLdYIGG9motap
+tTLeg+Xnk8yczBTTNanuNqIPWGiGH6JHoM6w6QIDAQABAoIBABC6fZJewb+L7/Oq
+oCKXQUZpdoIvxSLq9JfryqgAAAqH6tI6Iga9jcsPpqJel//ByTUnvo5sPJBzrzNn
+MtlJEnQjpaol1wf08sfREYPnCuM0XbQMO8VtaJ6iURHJbgl/n09i1g/dqsWyCQJD
+Kc1Gp1AYOsblfV67AWveolRYZD40ukSNZzblhKCM74fBGJU0glJXY5RzrXmuQKpN
+GiW0oxQaeuk2hD8D8LQj6nj/AB2jAt/LFXbPkb3da6WtBeFVgeO3uUZBwBoEg2UF
+kveoDKHwTLU9DqoPV8+3tnR2El+x42iYxX7Wj4QmftWNEMwI1uwCv8bNRbB8WZY6
+LKdlHJkCgYEA6ndIan+zor2KvVoQig/EW9/kGH96Mg6SBDNIyTqN5kFMkx1LhCcy
+cUlzmP6MH4+HqHCYCfkaVIzXy9QMDdgHtcLSbQ621kaM5/xc4Tujw1NSu1yh5RTn
+rUdanZcSC639gLp0prRIJ1mBmjWQzUjAgJmm5BKG2axgkSwd/kiTUPcCgYEA5kkh
+nll/wuvVOoj1URdl9CeweCDqgvlqT8lr4G9ANtwLxEGCGiUkqvrmYVA6uSKYdTMo
+qU+9xVU7rsrCutfeSzC5A90pmPPMVWzPWDkvaH+Xs5ZW//9H5LZ2wBtR4e5SnpH9
+cHSmootQjcjQoEcQzbEbYSNdeb88jU0puN4BdR8CgYBgd3tf9fKevoVaqrerVhlg
+A5oBSlGoTr5c5AzKXkELv0oWLTNoyAfE/IeHJxPX2GHkN48Wa1sd9mTDBBeBiqSB
+cArLvAYV2ykWOYqtULBsKNgdJluluRgo/vaVaBaQn6FxWUWtYPde2UCtheRx0cEC
+KDW2GLlKzdVdZV1hxdJ2uQKBgQCAtFbJvZOeSVg/AtS4oa8lqhkCysLGuMcmGJjm
+Msdc4dbhtQsVubSoqtmfgcuQNTmoJxOOrTaO13gn1MLI0mU2+OAuvKjHB1soU0v/
+LtbEPKt4f4nYQcDYnvH3pE07TIt4fHd9JnULW3mGBLo1GgLWMynuPGm95ZfeEUZE
+QC/oWQKBgCeHZyGKJBh4iC5RATdl48gzDKN3STWDDM6pNbEZ4lPw5QWSBpfI9UmV
+dugyG+Fgu8ax/HR+gOreapHPWunJs740D45xUwag97hWydIvypHs0IYsP51FJPhF
+P9zCH0GYkctbOAWTqoDQN7xo17Tv+PMYRHpUni8+ftFSMDeChg8l
+-----END RSA PRIVATE KEY-----
blob - /dev/null
blob + 01663e804ce8109141eecb15c0e455e6fe5b6c9b (mode 644)
--- /dev/null
+++ testdata/public.pem
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0uol3b0GtoECcWa5ogBj
+LCoBjwjuG2MqM2+Hn5n05U36yWE7Pb0L8t9Ifx/ulTdOQUDlnEJWtqFTL+PwqG4c
+CJMMy3m0cOEbhN5HwWHAFgbwjlQcDj31XToAZisiUtI80uT75ckq0NvJcitXyNzd
+XXQTQ0nKCH1Meok4MWI7nyGnceTaE3Mzh178v0xl9BkNSfRmqG8Lxw+mp8mqS0jI
+vLIGle9HTNeKMkISlV/4JgQqx5q90Zy7sJaOxKoeyw3blPdYyb90b90zPhPlASNd
+K+gZ695kygQgLapFLdYIGG9motaptTLeg+Xnk8yczBTTNanuNqIPWGiGH6JHoM6w
+6QIDAQAB
+-----END PUBLIC KEY-----
blob - /dev/null
blob + 71d39efe3ee7b43011253ae027a0520cd17bc503 (mode 644)
--- /dev/null
+++ webfinger.go
+package apub
+
+import (
+ "fmt"
+ "net/mail"
+ "os"
+ "os/user"
+ "path"
+ "strings"
+
+ "webfinger.net/go/webfinger"
+)
+
+func Finger(address string) (*Actor, error) {
+ jrd, err := webfinger.Lookup(address, nil)
+ if err != nil {
+ return nil, err
+ }
+ for i := range jrd.Links {
+ if jrd.Links[i].Type == ContentType {
+ return LookupActor(jrd.Links[i].Href)
+ }
+ }
+ return nil, ErrNotExist
+}
+
+func fingerAll(alist []*mail.Address) ([]string, error) {
+ actors := make([]string, len(alist))
+ for i, addr := range alist {
+ if strings.Contains(addr.Address, "+followers") {
+ addr.Address = strings.Replace(addr.Address, "+followers", "", 1)
+ a, err := Finger(addr.Address)
+ if err != nil {
+ return actors, fmt.Errorf("finger %s: %w", addr.Address, err)
+ }
+ actors[i] = a.Followers
+ continue
+ }
+ actor, err := Finger(addr.Address)
+ if err != nil {
+ return actors, fmt.Errorf("finger %s: %w", addr.Address, err)
+ }
+ actors[i] = actor.ID
+ }
+ return actors, nil
+}
+
+func UserWebFingerFile(username string) (string, error) {
+ u, err := user.Lookup(username)
+ if err != nil {
+ return "", err
+ }
+ if u.HomeDir == "" {
+ return "", fmt.Errorf("no home directory")
+ }
+
+ paths := []string{
+ path.Join(u.HomeDir, "lib/webfinger"), // Plan 9
+ path.Join(u.HomeDir, ".config/webfinger"), // Unix-like
+ path.Join(u.HomeDir, "Application Support/webfinger"), // macOS
+ }
+ for i := range paths {
+ if _, err := os.Stat(paths[i]); err == nil {
+ return paths[i], nil
+ }
+ }
+ return "", fmt.Errorf("no webfinger file")
+}