commit - 158b818071282f1db2ff66815dca7883ae8b4019
commit + 25fed994674728cf1905a717c1334f78c12781ea
blob - 005bdb3a278e9e0849e6e6b4267b8c5798f4bff6
blob + 0ba7ff4eb378b0ba7e6afead99b05991a4dd552b
--- apub.go
+++ apub.go
// @context
const AtContext string = "https://www.w3.org/ns/activitystreams"
+// ContentType is the MIME media type for ActivityPub.
const ContentType string = "application/activity+json"
-const AcceptMediaType string = `application/activity+json; profile="https://www.w3.org/ns/activitystreams"`
-
-// Activities addressed to this collection indicates the activity
-// is available to all users, authenticated or not.
+// PublicCollection is the ActivityPub ID for the special collection indicating public access.
+// Any Activity addressed to this collection is meant to be available to all users,
+// authenticated or not.
// See W3C Recommendation ActivityPub Section 5.6.
const PublicCollection string = "https://www.w3.org/ns/activitystreams#Public"
return nil
}
+// Unwrap returns the JSON-encoded Activity, if any, enclosed in act.
+// The Activity may be referenced by ID,
+// in which case the activity is looked up by client or by
+// apub.defaultClient if client is nil.
func (act *Activity) Unwrap(client *Client) (*Activity, error) {
if act.Object == nil {
return nil, errors.New("no wrapped activity")
PublicKeyPEM string `json:"publicKeyPem"`
}
+// Address generates the most likely address of the Actor.
+// The Actor's name (not the username) is used as the address' proper name, if present.
+// Implementors should verify the address using WebFinger.
+// For example, the followers address for Actor ID
+// https://hachyderm.io/users/otl is:
+//
+// "Oliver Lowe" <otl+followers@hachyderm.io>
func (a *Actor) Address() *mail.Address {
if a.Username == "" && a.Name == "" {
return &mail.Address{"", a.ID}
return &mail.Address{a.Name, addr}
}
+// FollowersAddress generates a non-standard address representing the Actor's followers
+// using plus addressing.
+// It is the Actor's address username part with a "+followers" suffix.
+// The address cannot be resolved using WebFinger.
+//
+// For example, the followers address for Actor ID
+// https://hachyderm.io/users/otl is:
+//
+// "Oliver Lowe (followers)" <otl+followers@hachyderm.io>
func (a *Actor) FollowersAddress() *mail.Address {
if a.Followers == "" {
return &mail.Address{"", ""}
blob - e5c409b090790dfdb9623c5fe12fad3e4def11e1
blob + bfcb8e5d35c75965c081cafe858dbf41b98e644d
--- client.go
+++ client.go
type Client struct {
*http.Client
- Key *rsa.PrivateKey
- Actor *Actor
+ // Key is a RSA private key which will be used to sign requests.
+ Key *rsa.PrivateKey
+ // PubKeyID is a URL where the corresponding public key of Key
+ // may be accessed. This must be set if Key is also set.
+ PubKeyID string // actor.PublicKey.ID
}
func (c *Client) Lookup(id string) (*Activity, error) {
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 {
+ if c.Key != nil && c.PubKeyID != "" {
+ if err := Sign(req, c.Key, c.PubKeyID); err != nil {
return nil, fmt.Errorf("sign http request: %w", err)
}
}
return nil, err
}
req.Header.Set("Content-Type", ContentType)
- if err := Sign(req, c.Key, c.Actor.PublicKey.ID); err != nil {
+ if err := Sign(req, c.Key, c.PubKeyID); err != nil {
return nil, fmt.Errorf("sign outgoing request: %w", err)
}
resp, err := c.Do(req)
blob - 876dfda95b73ac0831d8bbaf14680b2993c4b797 (mode 644)
blob + /dev/null
--- cmd/apmail/listen.go
+++ /dev/null
-package main
-
-import (
- "encoding/json"
- "fmt"
- "log"
- "net/http"
- "net/smtp"
- "os"
- "os/user"
- "path"
- "strings"
-
- "olowe.co/apub"
-)
-
-type server struct {
- acceptFor []user.User
- relayAddr string
-}
-
-func (srv *server) relay(username string, 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 from %s: %v", activity.ID, err)
- return
- }
- srv.relay(username, wrapped)
- return
- default:
- return
- }
-
- if err := apub.SendMail(srv.relayAddr, nil, "nobody", []string{username}, activity); 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
- }
- // url is https://example.com/{username}/inbox
- username := path.Dir(req.URL.Path)
- _, err := user.Lookup(username)
- if _, ok := err.(user.UnknownUserError); ok {
- w.WriteHeader(http.StatusNotFound)
- return
- } else if err != nil {
- log.Println("handle inbox:", err)
- w.WriteHeader(http.StatusInternalServerError)
- return
- }
- var accepted bool
- for i := range srv.acceptFor {
- if srv.acceptFor[i].Username == username {
- accepted = true
- }
- }
- if !accepted {
- w.WriteHeader(http.StatusNotFound)
- 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)
- http.Error(w, "malformed activitypub message", http.StatusBadRequest)
- 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)
- http.Error(w, err.Error(), http.StatusBadRequest)
- 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)
- return
- case "Create", "Note", "Page", "Article":
- w.WriteHeader(http.StatusAccepted)
- log.Printf("accepted %s %s for relay to %s", activity.Type, activity.ID, username)
- go srv.relay(username, activity)
- return
- }
- w.WriteHeader(http.StatusAccepted)
-}
-
-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 serveActorFile(name string) http.HandlerFunc {
- return func(w http.ResponseWriter, req *http.Request) {
- 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)
- stat := http.StatusInternalServerError
- http.Error(w, http.StatusText(stat), stat)
- 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)
- }
-
- current, err := user.Current()
- if err != nil {
- log.Fatalf("lookup current user: %v", err)
- }
- acceptFor := []user.User{*current}
-
- 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{
- relayAddr: raddr,
- acceptFor: acceptFor,
- }
- http.HandleFunc("/.well-known/webfinger", serveWebFingerFile)
-
- for _, u := range acceptFor {
- dataDir := path.Join(u.HomeDir, "apubtest")
- root := fmt.Sprintf("/%s/", u.Username)
- inbox := path.Join(root, "inbox")
- hfsys := serveActivityFile(http.FileServer(http.Dir(dataDir)))
- http.Handle(root, http.StripPrefix(root, hfsys))
- http.HandleFunc(inbox, srv.handleInbox)
- }
- log.Fatal(http.ListenAndServe("[::1]:8082", nil))
-}
blob - /dev/null
blob + 5ad0de9649498f47f2554e606336495f5627d036 (mode 644)
--- /dev/null
+++ cmd/apsend/apsend.go
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/mail"
+ "os"
+ "os/user"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+
+ "olowe.co/apub"
+ "olowe.co/apub/internal/sys"
+)
+
+const usage string = "apsend [-F] [-t] rcpt ..."
+
+// Delivers the mail message to the user's Maildir.
+func deliverLocal(username string, msg []byte) error {
+ u, err := user.Lookup(username)
+ if err != nil {
+ return err
+ }
+ inbox := path.Join(u.HomeDir, "Maildir/new")
+ fname := fmt.Sprintf("%s/%d", inbox, time.Now().Unix())
+ return os.WriteFile(fname, msg, 0664)
+}
+
+func wrapCreate(activity *apub.Activity) (*apub.Activity, error) {
+ b, err := json.Marshal(activity)
+ if err != nil {
+ return nil, err
+ }
+ return &apub.Activity{
+ AtContext: activity.AtContext,
+ ID: activity.ID + "-create",
+ Actor: activity.AttributedTo,
+ Type: "Create",
+ Published: activity.Published,
+ To: activity.To,
+ CC: activity.CC,
+ Object: b,
+ }, nil
+}
+
+var tflag bool
+var Fflag bool
+
+func init() {
+ // log.SetFlags...
+ flag.BoolVar(&Fflag, "F", false, "file a copy for the sender")
+ flag.BoolVar(&tflag, "t", false, "read recipients from message")
+ flag.Parse()
+}
+
+const sysName string = "apubtest2.srcbeat.com"
+
+func main() {
+ if tflag {
+ log.Fatal("flag -t not implemented yet")
+ }
+ if len(flag.Args()) == 0 {
+ fmt.Fprintln(os.Stderr, "usage:", usage)
+ os.Exit(1)
+ }
+
+ bmsg, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ log.Fatal(err)
+ }
+ msg, err := mail.ReadMessage(bytes.NewReader(bmsg))
+ if err != nil {
+ log.Fatal(err)
+ }
+ activity, err := apub.UnmarshalMail(msg)
+ if err != nil {
+ log.Fatalln("unmarshal activity from message:", err)
+ }
+
+ var remote []string
+ for _, rcpt := range flag.Args() {
+ if !strings.Contains(rcpt, "@") {
+ if err := deliverLocal(rcpt, bmsg); err != nil {
+ log.Printf("local delivery to %s: %v", rcpt, err)
+ }
+ continue
+ }
+ remote = append(remote, rcpt)
+ }
+
+ var gotErr bool
+ if len(remote) > 0 {
+ if !strings.HasPrefix(activity.AttributedTo, "https://"+sysName) {
+ log.Fatalln("cannot send activity from non-local actor", activity.AttributedTo)
+ }
+
+ from, err := apub.LookupActor(activity.AttributedTo)
+ if err != nil {
+ log.Fatalf("lookup actor %s: %v", activity.AttributedTo, err)
+ }
+ client, err := sys.ClientFor(from.Username, sysName)
+ if err != nil {
+ log.Fatalf("apub cilent for %s: %v", from.Username, err)
+ }
+
+ // overwrite auto generated ID from mail clients
+ if !strings.HasPrefix(activity.ID, "https://") {
+ activity.ID = from.Outbox + "/" + strconv.Itoa(int(activity.Published.Unix()))
+ bmsg, _ = apub.MarshalMail(activity)
+ }
+
+ // Permit this activity for the public, too;
+ // let's not pretend the fediverse is not public access.
+ activity.To = append(activity.To, apub.PublicCollection)
+ create, err := wrapCreate(activity)
+ if err != nil {
+ log.Fatalf("wrap %s %s in Create activity: %v", activity.Type, activity.ID, err)
+ }
+
+ // append outbound activities to the user's outbox so others can fetch it.
+ sysuser, err := user.Lookup(from.Username)
+ if err != nil {
+ log.Fatalf("lookup system user from %s: %v", activity.ID, err)
+ }
+ outbox := path.Join(sys.UserDataDir(sysuser), "outbox")
+ for _, a := range []*apub.Activity{activity, create} {
+ b, err := json.Marshal(a)
+ if err != nil {
+ log.Fatalf("encode %s: %v", activity.ID, err)
+ }
+ fname := path.Base(a.ID)
+ fname = path.Join(outbox, fname)
+ if err := os.WriteFile(fname, b, 0644); err != nil {
+ log.Fatalf("write activity to outbox: %v", err)
+ }
+ }
+
+ for _, rcpt := range remote {
+ ra, err := apub.Finger(rcpt)
+ if err != nil {
+ log.Printf("webfinger %s: %v", rcpt, err)
+ gotErr = true
+ continue
+ }
+ if _, err = client.Send(ra.Inbox, create); err != nil {
+ log.Printf("send %s %s to %s: %v", activity.Type, activity.ID, rcpt, err)
+ gotErr = true
+ }
+ }
+ if Fflag {
+ if err := deliverLocal(from.Username, bmsg); err != nil {
+ log.Printf("file copy for %s: %v", from.Username, err)
+ gotErr = true
+ }
+ }
+ }
+
+ if gotErr {
+ os.Exit(1)
+ }
+}
blob - /dev/null
blob + f98f969282313d199dc35907a74d9c0b7f66367e (mode 644)
--- /dev/null
+++ cmd/apsend/doc.go
+/*
+Command apsend reads a mail message from the standard input
+and disposes of it based on the provided recipient addresses.
+
+Its usage is:
+
+ apsend [ -F ] [ -t ] rcpt ...
+
+Messages are disposed of in one of two ways:
+
+ - If the recipient refers to a local user, the message is appended to their local mailbox.
+ - If the recipients refers to a remote user or collection, the message is sent via ActivityPub to each recipent's corresponding Actor inbox.
+
+Local recipients are addrsessed as a plain username such as "otl".
+Remote recipients are addressed as an email address,
+such as
+"mort@novum.streats.dev" or
+"otl+followers@hachyderm.io".
+
+apsend is not intended to be executed directly by users.
+Usually it is executed as a mailer by a SMTP server like [apsubmit],
+or by a server which receives ActivityPub activities for local recipients like [apserve].
+
+The flags understood are:
+
+ - *-F* File a copy to the sender's mailbox.
+
+ - *-t* Read recipients from the To: and CC: lines of the message.
+
+# Example
+
+Given the following message in the file greeting.eml:
+
+ From: otl@apubtest2.srcbeat.com
+ To: otl@hachyderm.io
+
+ Hello, Oliver!
+
+Send it with the following command:
+
+ apsend otl@hachyderm.io < greeting.eml
+*/
+package main
blob - /dev/null
blob + 7bf7d0b29c57bd0e8278ae997f90b413dc67756f (mode 644)
--- /dev/null
+++ cmd/apsend/msg.go
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/mail"
+ "strings"
+)
+
+func encodMsg(msg *mail.Message) []byte {
+ buf := &bytes.Buffer{}
+ fmt.Fprintln(buf, msg.Header.Get("From"))
+ delete(msg.Header, "From")
+ for k, v := range msg.Header {
+ if k == "Subject" {
+ continue
+ }
+ fmt.Fprintf(buf, "%s: %s\n", k, strings.Join(v, ", "))
+ }
+ fmt.Fprintln(buf, "Subject:", msg.Header.Get("Subject"))
+ fmt.Fprintln(buf)
+ io.Copy(buf, msg.Body)
+ return buf.Bytes()
+}
blob - /dev/null
blob + eab3871c626f8918fd0f0d200573a4c080f291d6 (mode 644)
--- /dev/null
+++ cmd/apserve/listen.go
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/user"
+ "path"
+ "strings"
+
+ "olowe.co/apub"
+)
+
+type server struct {
+ acceptFor []user.User
+ relayAddr string
+}
+
+func (srv *server) relay(username string, 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 from %s: %v", activity.ID, err)
+ return
+ }
+ srv.relay(username, wrapped)
+ return
+ default:
+ return
+ }
+
+ cmd := exec.Command("apsend", username)
+ msg, err := apub.MarshalMail(activity)
+ if err != nil {
+ log.Printf("marshal %s %s to mail message: %v", activity.Type, activity.ID, err)
+ return
+ }
+ cmd.Stdin = bytes.NewReader(msg)
+ cmd.Stderr = os.Stderr
+ err = cmd.Run()
+ if err != nil {
+ log.Printf("execute mailer for %s: %v", activity.ID, err)
+ return
+ }
+}
+
+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
+ }
+ // url is https://example.com/{username}/inbox
+ username := strings.Trim(path.Dir(req.URL.Path), "/")
+ _, err := user.Lookup(username)
+ if _, ok := err.(user.UnknownUserError); ok {
+ log.Println("handle inbox:", err)
+ w.WriteHeader(http.StatusNotFound)
+ return
+ } else if err != nil {
+ log.Println("handle inbox:", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ var accepted bool
+ for i := range srv.acceptFor {
+ if srv.acceptFor[i].Username == username {
+ accepted = true
+ }
+ }
+ if !accepted {
+ w.WriteHeader(http.StatusNotFound)
+ 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)
+ http.Error(w, "malformed activitypub message", http.StatusBadRequest)
+ 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)
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ 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)
+ return
+ case "Create", "Note", "Page", "Article":
+ w.WriteHeader(http.StatusAccepted)
+ log.Printf("accepted %s %s for %s", activity.Type, activity.ID, username)
+ go srv.relay(username, activity)
+ return
+ }
+ w.WriteHeader(http.StatusAccepted)
+}
+
+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 serveActorFile(name string) http.HandlerFunc {
+ return func(w http.ResponseWriter, req *http.Request) {
+ 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)
+ }
+}
+
+const usage string = "apserve"
+
+const domain = "apubtest2.srcbeat.com"
+
+func main() {
+ if len(os.Args) > 1 {
+ log.Fatalln("usage:", usage)
+ }
+
+ current, err := user.Current()
+ if err != nil {
+ log.Fatalf("lookup current user: %v", err)
+ }
+ acceptFor := []user.User{*current}
+
+ srv := &server{
+ acceptFor: acceptFor,
+ }
+ http.HandleFunc("/.well-known/webfinger", serveWebFingerFile)
+
+ for _, u := range acceptFor {
+ dataDir := path.Join(u.HomeDir, "apubtest")
+ root := fmt.Sprintf("/%s/", u.Username)
+ hfsys := serveActivityFile(http.FileServer(http.Dir(dataDir)))
+ http.Handle(root, http.StripPrefix(root, hfsys))
+ inbox := path.Join(root, "inbox")
+ http.HandleFunc(inbox, srv.handleInbox)
+ }
+ log.Fatal(http.ListenAndServe("[::1]:8082", nil))
+}
blob - /dev/null
blob + 356e4b4df27a20951d0e75d0bc7add8040b38f5c (mode 644)
--- /dev/null
+++ cmd/apserve/user.go
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "os/user"
+ "strings"
+
+ "olowe.co/apub"
+ "olowe.co/apub/internal/sys"
+)
+
+func serveActor(w http.ResponseWriter, req http.Request, username string) {
+ actor, err := sys.Actor(username, domain)
+ if err != nil {
+ // for security reasons we lie here; prevents user enumeration
+ log.Println("lookup actor:", err)
+ http.Error(w, "no such actor", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", apub.ContentType)
+ if err := json.NewEncoder(w).Encode(actor); err != nil {
+ log.Printf("encode actor %s: %v", actor.Username, err)
+ }
+}
+
+func serveWebFinger(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
+ }
+ jrd, err := sys.JRDFor(username, domain)
+ if _, ok := err.(user.UnknownUserError); ok {
+ http.Error(w, "no such user", http.StatusNotFound)
+ return
+ } else if err != nil {
+ err = fmt.Errorf("webfinger jrd for %s: %v", username, err)
+ log.Print(err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if err := json.NewEncoder(w).Encode(jrd); err != nil {
+ log.Printf("encode webfinger response: %v", err)
+ }
+}
blob - /dev/null
blob + 55b792985762870e72f6b60e22f53c8748830d2f (mode 644)
--- /dev/null
+++ cmd/apsubmit/apsubmit.go
+package main
+
+import (
+ "log"
+
+ "github.com/emersion/go-smtp"
+)
+
+func main() {
+ srv := smtp.NewServer(&Backend{})
+ srv.Addr = ":2525"
+ srv.Domain = "apubtest2.srcbeat.com"
+ srv.AllowInsecureAuth = true
+
+ if err := srv.ListenAndServe(); err != nil {
+ log.Fatal(err)
+ }
+}
blob - /dev/null
blob + 79f86165289e764f031e0ac473cb523d7918b0bb (mode 644)
--- /dev/null
+++ cmd/apsubmit/server.go
+package main
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/mail"
+ "os"
+ "os/exec"
+ "os/user"
+
+ "github.com/emersion/go-smtp"
+ "olowe.co/apub"
+)
+
+type Backend struct {
+ auth string
+}
+
+func (be *Backend) NewSession(conn *smtp.Conn) (smtp.Session, error) {
+ return &Session{}, nil
+}
+
+type Session struct {
+ recipients []string
+ User *user.User
+}
+
+func (s *Session) AuthPlain(username, password string) error {
+ u, err := user.Lookup(username)
+ if err != nil {
+ return errors.New("invalid username or password")
+ }
+ // TODO allow other users except for me lol
+ // TODO implement BSD Auth and/or PAM?
+ if u.Username != "otl" {
+ return errors.New("invalid username or password")
+ }
+ if password != "yamum" {
+ return errors.New("invalid username or password")
+ }
+ s.User = u
+ return nil
+}
+
+func (s *Session) Logout() error { return nil }
+func (s *Session) Reset() {}
+
+func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
+ log.Println("MAIL FROM:", from)
+ return nil
+}
+
+func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
+ log.Println("RCPT TO:", to)
+ addr, err := mail.ParseAddress(to)
+ if err != nil {
+ return err
+ }
+ if _, err = apub.Finger(addr.Address); err != nil {
+ return err
+ }
+ s.recipients = append(s.recipients, to)
+ return nil
+}
+
+func (s *Session) Data(r io.Reader) error {
+ args := append([]string{"-F"}, s.recipients...)
+ cmd := exec.Command("apsend", args...)
+ cmd.Stdin = r
+ cmd.Stderr = os.Stderr
+ err := cmd.Run()
+ if err1, ok := err.(*exec.ExitError); ok {
+ return fmt.Errorf("execute mailer: %v", string(err1.Stderr))
+ } else if err != nil {
+ return fmt.Errorf("execute mailer: %v", err)
+ }
+ return nil
+}
blob - afc05ba205700e52300473ed8e2a3a1b3ed6faef
blob + 1e25afd5c580e5a4af28ac994ad0df2b418c244e
--- mail.go
+++ mail.go
return nil, fmt.Errorf("webfinger From: %w", err)
}
- to, err := msg.Header.AddressList("To")
- if err != nil {
- return nil, fmt.Errorf("parse To address list: %w", err)
+ var wto, wcc []string
+ if msg.Header.Get("To") != "" {
+ to, err := msg.Header.AddressList("To")
+ // ignore missing To line. Some ActivityPub servers only have the
+ // PublicCollection listed, which we don't care about.
+ if err != nil {
+ return nil, fmt.Errorf("parse To address list: %w", err)
+ }
+ wto, err = fingerAll(to)
+ if err != nil {
+ return nil, fmt.Errorf("webfinger To addresses: %w", err)
+ }
}
- wto, err := fingerAll(to)
- if err != nil {
- return nil, fmt.Errorf("webfinger To addresses: %w", err)
- }
- var wcc []string
if msg.Header.Get("CC") != "" {
cc, err := msg.Header.AddressList("CC")
if err != nil {
blob - /dev/null
blob + 8aa85921ec7f83e153d9158bee7c546bc60713f2 (mode 644)
--- /dev/null
+++ internal/sys/user.go
+package sys
+
+import (
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "os/user"
+ "path"
+
+ "olowe.co/apub"
+ "webfinger.net/go/webfinger"
+)
+
+func UserDataDir(u *user.User) string {
+ return path.Join(u.HomeDir, "apubtest")
+}
+
+func ConfigDir(u *user.User) (string, error) {
+ paths := []string{
+ path.Join(u.HomeDir, ".config/apubtest"), // Unix-like
+ path.Join(u.HomeDir, "Application Support/apubtest"), // macOS
+ path.Join(u.HomeDir, "lib/apubtest"), // Plan 9
+ }
+ for i := range paths {
+ if _, err := os.Stat(paths[i]); err == nil {
+ return paths[i], nil
+ }
+ }
+ return "", fmt.Errorf("no apubtest dir")
+}
+
+func Actor(name, host string) (*apub.Actor, error) {
+ u, err := user.Lookup(name)
+ if err != nil {
+ return nil, err
+ }
+ uri, err := url.Parse("https://" + host)
+ if err != nil {
+ return nil, fmt.Errorf("bad host: %w", err)
+ }
+ uri.Path = path.Join("/", u.Username)
+ root := uri.String()
+
+ cdir, err := ConfigDir(u)
+ if err != nil {
+ return nil, fmt.Errorf("find config directory: %w", err)
+ }
+ pubkey, err := os.ReadFile(path.Join(cdir, "public.pem"))
+ if err != nil {
+ return nil, fmt.Errorf("read public key file: %w", err)
+ }
+ return &apub.Actor{
+ AtContext: apub.AtContext,
+ ID: root + "/actor.json",
+ Type: "Person",
+ Name: u.Name,
+ Username: u.Username,
+ Inbox: root + "/inbox",
+ Outbox: root + "/outbox",
+ PublicKey: apub.PublicKey{
+ ID: root + "/actor.json#main-key",
+ Owner: root + "/actor.json",
+ PublicKeyPEM: string(pubkey),
+ },
+ }, nil
+}
+
+func ClientFor(username, host string) (*apub.Client, error) {
+ sysuser, err := user.Lookup(username)
+ if err != nil {
+ return nil, err
+ }
+ actor, err := Actor(sysuser.Username, host)
+ if err != nil {
+ return nil, fmt.Errorf("load system actor: %w", err)
+ }
+ cdir, err := ConfigDir(sysuser)
+ if err != nil {
+ return nil, fmt.Errorf("find config dir: %w", err)
+ }
+
+ key, err := loadKey(path.Join(cdir, "private.pem"))
+ if err != nil {
+ return nil, fmt.Errorf("load private key: %w", err)
+ }
+ return &apub.Client{
+ Client: http.DefaultClient,
+ Key: key,
+ PubKeyID: actor.PublicKey.ID,
+ }, nil
+}
+
+func loadKey(name string) (*rsa.PrivateKey, error) {
+ b, err := os.ReadFile(name)
+ if err != nil {
+ return nil, err
+ }
+ block, _ := pem.Decode(b)
+ return x509.ParsePKCS1PrivateKey(block.Bytes)
+}
+
+func JRDFor(username, domain string) (*webfinger.JRD, error) {
+ if _, err := user.Lookup(username); err != nil {
+ return nil, err
+ }
+ return &webfinger.JRD{
+ Subject: fmt.Sprintf("acct:%s@%s", username, domain),
+ Links: []webfinger.Link{
+ webfinger.Link{
+ Rel: "self",
+ Type: apub.ContentType,
+ Href: fmt.Sprintf("https://%s/%s/actor.json", domain, username),
+ },
+ },
+ }, nil
+}
blob - /dev/null
blob + d1361750b4e59aef58727bc664032877b9cc84e5 (mode 644)
--- /dev/null
+++ internal/sys/user_test.go
+package sys
+
+import "testing"
+
+func TestJRD(t *testing.T) {
+ jrd, err := JRDFor("nobody", "example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if jrd.Subject != "acct:nobody@example.com" {
+ t.Errorf("unexpected subject %s", jrd.Subject)
+ }
+ if jrd.Links[0].Href != "https://example.com/nobody/actor.json" {
+ t.Errorf("unexpected href %s", jrd.Links[0].Href)
+ }
+}