Add support basic for ActivityPub (#1139)
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
Closes #1138 (supercedes it) What works now: - [x] You (Yarn.social) are discoverable in the Fediverse via Activity Pub - [x] You (Yarn.social) can be followed in the Fediverse via Activity Pub - [x] Posts you make (on Yarn.social) are delivered to your Fediverse followers via Activity Pub Co-authored-by: James Mills <1290234+prologic@users.noreply.github.com> Reviewed-on: #1139pull/1143/head
parent
323a688b3a
commit
78ba871111
@ -0,0 +1,27 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEpQIBAAKCAQEA0FHKNv6CrWyArlUmlpWILDIVcfAxKDFvxUqcmDo436eSp354
|
||||
NHsPd8TD2pj1sMbDBQRY/cvJrqXNPRo/fsCAqfPkff60gRCb09MS12u7AvzZH2Wl
|
||||
kZvrjYC/xV1r9BfkWiOZJKQuQ2N8oJdq50Qm2z0XO07tKKX9HzU9Cg/TImlzVyH0
|
||||
sWIEoe6TX7gbzONZl/7PFqWPMUbOGbWsDTrXh5mYvdS9Tb0XFYwKzmitVLRo7f+w
|
||||
oUsRf/v3Brj8KiGsOqCiIORxt+/YiFLuOTpFE/ErSSPgMGI1zoOctEZg7fDm6YjQ
|
||||
2BkENoJo5jVlmFi+lXNy+cH6v6Er5GyjJRDtEwIDAQABAoIBAQC3aItEx+d9kJ3q
|
||||
3wVOZvCxGJdQ7UwaOwxRA1PDot8X6o1v8iUa7426wP4+o5UMwrJI5H8FVDqJDWkZ
|
||||
dtaYXhvphdWSKIH7cAgCAz//cdYA12TCA9g1zrUgrE4rEglNqwtdYSIf5Hzmz9yV
|
||||
9zojyxj6xVqC2QZsV1f39gN7rFsTW3QL4Kuqu//JVg9+ceX8gh0DRTFEqGjZ5MAy
|
||||
QPo1yVQc/tyBBoYfH9FyuQtl45lmllWZB+PDAxYRFAIqtt6lKY/q5xa3p4G6IKds
|
||||
GLpvPDWYprzAwYqUTZPqQo6O3Gcit4TLoCTsAh7HiGMOzfkBS/f2071DPT9Cuy/w
|
||||
jQ2prGBBAoGBAPvJSwZ+91dCg9azEDA/1U1VYvWKw3l41+YTF6yLu7lVrUV/3wq5
|
||||
VYmPnxiwo0asUsThyFmhLr0vQIufVwBG95ZmnYVG7jiHl/jZX3fzNcEBzowaN5cQ
|
||||
Uw+LMbd+0QEE5wrNGTt97YLID8DG1JTgwC8SOqVK2uImyTdt/KVgFJgbAoGBANPO
|
||||
Ro+XKu/LL6LMD9gtqhukAxlo19scF9SY6wAGp3LI1IUTdcf+NAxwLKpsW+8WM2i9
|
||||
e+kdt7wZUR5aKBrkI7ogpeF1OFw2z3Ip3ahR8xo5X09ptWElfTKLajR++JY82H1q
|
||||
xE4UaS6CoT+vbP2VseXNviLR6AaMaOsFvWTL2D5pAoGBAMWDCDmWS8zFvsojOWXA
|
||||
DUFW5AQd0G1voF05SO7vxlkCnqPQRVUSQclhQrqJheugrmUHgLTevd1mPcnJOuRa
|
||||
x0nSQWsVUzZBF7P8QOnFfbtkAXTh9A2qnp2o4V1CPA4CnINalJqYlEJtUf41evk2
|
||||
vUuvjxWu/Lk/F8VFoFTSZBVVAoGAfaeCdO1Zq6j/ObWWMMnmgT9sF0b7yCGCgb22
|
||||
rO1FqfM7ITfKSDum5TonRXPDlrO1DA5d1I6s0gqy9S7HXCy8hU+ZGYhRR2O87h2o
|
||||
QpNbhdNDl/k+gcOb4sCS4VHyaC7wwHb2vtudCtq0jvOj1U1ZnNvSURX2cOwb0lI1
|
||||
afcE8wECgYEAsH3SGDcqc3seesfVuZfmJTqwYzsMLjRZ0YGDouxvvJ+d5bKjVYWe
|
||||
FCeiz2sK6mNFJwxhS2jXvwwDds08h0rc90bHi2MM8wq2F79yenZphJv8Tcdb0vVQ
|
||||
789uEQNxNuIAD/s7YARBT3geyOQc/LgTaLm4Jz9Kr5QMp4gIFG9LbWg=
|
||||
-----END PRIVATE KEY-----
|
@ -0,0 +1,27 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEpQIBAAKCAQEApVH9lrxxCYMIxpgswJX3zM/V0IFg7PecFp20OCgOMJjMfFz5
|
||||
8k4ZpORHwULwCYR2OZIAIkxE6XQftsOc83qSK69nwd9oB92XSzXJZ0xF1jriijWl
|
||||
gMnnnNdwTsmxAxUDY4S7R6G62xr8XWMrUzyNSOulWiaDkLW49dDT8SjgQATo/jt2
|
||||
je/xp9kuHMCM7DXgkALa6SHXGmseckr07dJw2CsswaDew6wfn+wecUZpsaqgsz4z
|
||||
P5lElADRfm+sREZl/2I8ZNbJaPqvgPS6vlEoum2Ce8Is57AHbRn6iZbxyLfix4i8
|
||||
ps90uwXS62FJZuCI99fuc51RFO+i8KqNSf03NQIDAQABAoIBAQCYvYX0LKqrRRto
|
||||
kNRYIrbNzgAYIlDw31yhMJd/gtKJZ8MV67kqe6oJxLffAP9Ra8bnLdNd3OWWY6mh
|
||||
bF2oPsip/+d3Ife1vK+51zn7bGbhpYoEc8gzk1egexPSV1pqJJH68nktl2lSYj5j
|
||||
8ennf3xxsPYWspq/qoX25zfKCiAWRHZyCkXSksaL34G1SDy2MyK48INykNfq8uN/
|
||||
owZdT2SFqkvIbYjnY68VXfuD7Y71Cni7c/17gJPQ3/Z6Jl7Y5V/BzrqKYV9kCmKx
|
||||
/+RjwTk46YlZnEr5AaL9cye2pWBBWEBeAp+ia/qup+HE9MWZVqZg+K0jfbpFptEn
|
||||
pd3rw5CJAoGBAMNkwHp6RAakGvPHLlWeSxy+psewoesbIPBATAvP6xx8wDcsNYfW
|
||||
qICBTKQTZMYlFXeBI4zpIurGjW8+tiCKsKnL5v9mMA3nAJzpgGOkX4bCwszlAg2h
|
||||
UQN/corjPF12Kt5/RHmgTpMOgwT96DcyZ0H8weBTdlem+fPqq9/MasVLAoGBANiZ
|
||||
RBcOSFznQVQlhFcN2zxywC6SGtSZ0QYHJdut2R2xtudD0KbN1+/RSjsrFS6LHR9/
|
||||
SSlJaAEPnlM/cTGOHvy/ctTqcTduJh2qyC+/23DDL9HcO8TpFX3zH9VqngcU8rL+
|
||||
0LBsIrZ8wAXTVW1vMshDclVFrO61zeDz/PIyFKV/AoGAYh8jZZ4msSsR+d/Jjedr
|
||||
EulO+bLi7Rz3go7XYYstJ2YiZNKHo0qR3c6QvUib78FJsXShdK7TARFqjzXv4hGj
|
||||
u/EQdKtNcH3T2fiMp/0wl40QpDJQwKWE+Hu0+rg4ZTrlNky1B1sQelrsQsJ7LdTa
|
||||
89FJTyy6njPVC7+KRl3yNFcCgYEAx/6U2XkNpgK91pWhocQgl6sY+qdbcMzqLNey
|
||||
xCm83Oc4DEgYw7wzU7N7CDEaqNQ4utmL0zx9dOVX+mQM/4XL5PJddG1YxqbFOQV1
|
||||
PGm5lGAVqn/hDEtv1dEEpsmASuezxUT1qsDeOIPtxjNBoP9Y84MbcdMY/30NMVX4
|
||||
TCWj3L8CgYEAg8qSqduZ0zQPJLlU6nUbYf2JpqVdq9nDCfq4k3amJrlm7nGKojLp
|
||||
potcnRGiQVi1HQ13/4mF+Ka/6Qht3AH+BZsG8QypWuB16DjIFbZ1QOzf/j7Z1uxx
|
||||
DT/0np4v5Al/bjqcIv17E1b8jibcFBkpK5rxXA4g76uXvQ5xuUKFuRI=
|
||||
-----END PRIVATE KEY-----
|
@ -0,0 +1,382 @@
|
||||
// Copyright 2020-present Yarn.social
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package activitypub implements ActivityPub types, handling and c2s (client-to-server) functionality
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
sync "github.com/sasha-s/go-deadlock"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRedeliveryAttempts = 6
|
||||
defaultQueueSize = 100
|
||||
emptyDigest = "SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE="
|
||||
)
|
||||
|
||||
func fileExists(fn string) bool {
|
||||
if _, err := os.Stat(fn); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type activity struct {
|
||||
actor *Actor
|
||||
inbox string
|
||||
object any
|
||||
attempts int
|
||||
}
|
||||
|
||||
type Follower struct {
|
||||
Actor string `json:"actor"`
|
||||
Inbox string `json:"inbox"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func NewFollower(actor string) *Follower {
|
||||
return &Follower{
|
||||
Actor: actor,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
type Followers []*Follower
|
||||
|
||||
func (f Followers) Len() int { return len(f) }
|
||||
func (f Followers) Swap(i, j int) { f[i], f[j] = f[j], f[i] }
|
||||
func (f Followers) Less(i, j int) bool { return f[i].CreatedAt.Before(f[j].CreatedAt) }
|
||||
|
||||
type Stats struct {
|
||||
Actors int
|
||||
Followers int
|
||||
}
|
||||
|
||||
type ActivityPub struct {
|
||||
sync.RWMutex
|
||||
|
||||
fn string
|
||||
actor *Actor
|
||||
|
||||
// Mapping of Actor -> Followers
|
||||
followers map[string]Followers
|
||||
|
||||
// inbox queue for processing received objects asynchronously
|
||||
inbox chan any
|
||||
inboxTicker *time.Ticker
|
||||
|
||||
// outbox queue for sending activities to followers
|
||||
outbox chan *activity
|
||||
outboxTicker *time.Ticker
|
||||
|
||||
stateTicker *time.Ticker
|
||||
|
||||
// ValidateActor is a function that takes an `actor` string as input and returns `true` if it is
|
||||
// a valid actor or `false` otherwise. Consumers should override this field with a custom
|
||||
// actor validation function that suits the application.
|
||||
ValidateActor func(actor string) bool
|
||||
}
|
||||
|
||||
// New returns a new ActivityPub endpoint and processor
|
||||
func New(fn string, actor *Actor) *ActivityPub {
|
||||
ap := &ActivityPub{
|
||||
fn: fn,
|
||||
actor: actor,
|
||||
followers: make(map[string]Followers),
|
||||
inbox: make(chan any, defaultQueueSize),
|
||||
outbox: make(chan *activity, defaultQueueSize),
|
||||
|
||||
ValidateActor: func(id string) bool { return true },
|
||||
}
|
||||
|
||||
ap.inboxTicker = time.NewTicker(1 * time.Second)
|
||||
go func() {
|
||||
for range ap.inboxTicker.C {
|
||||
ap.processInbox()
|
||||
}
|
||||
}()
|
||||
|
||||
ap.outboxTicker = time.NewTicker(2 * time.Second)
|
||||
go func() {
|
||||
for range ap.outboxTicker.C {
|
||||
ap.processOutbox()
|
||||
}
|
||||
}()
|
||||
|
||||
ap.stateTicker = time.NewTicker(1 * time.Minute)
|
||||
go func() {
|
||||
for range ap.stateTicker.C {
|
||||
ap.Save()
|
||||
}
|
||||
}()
|
||||
|
||||
return ap
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) Load() error {
|
||||
if !fileExists(ap.fn) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(ap.fn)
|
||||
if err != nil {
|
||||
os.Remove(ap.fn)
|
||||
return fmt.Errorf("error loading state: %w", err)
|
||||
}
|
||||
|
||||
state := struct {
|
||||
Followers map[string]Followers
|
||||
}{}
|
||||
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
os.Remove(ap.fn)
|
||||
return fmt.Errorf("error parsing state: %w", err)
|
||||
}
|
||||
|
||||
ap.Lock()
|
||||
ap.followers = state.Followers
|
||||
ap.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) Save() error {
|
||||
ap.RLock()
|
||||
defer ap.RUnlock()
|
||||
|
||||
state := struct {
|
||||
Followers map[string]Followers
|
||||
}{
|
||||
Followers: ap.followers,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error serializing state: %s", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(ap.fn, data, 0644); err != nil {
|
||||
return fmt.Errorf("error saving state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) addFollower(actor string, follower *Follower) {
|
||||
ap.followers[actor] = append(ap.followers[actor], follower)
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) AddFollower(actor string, follower *Follower) {
|
||||
ap.Lock()
|
||||
defer ap.Unlock()
|
||||
|
||||
ap.addFollower(actor, follower)
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) getFollowerFor(id, actor string) (*Follower, int) {
|
||||
followers, ok := ap.followers[id]
|
||||
if !ok {
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
for idx, follower := range followers {
|
||||
if follower.Actor == actor {
|
||||
return follower, idx
|
||||
}
|
||||
}
|
||||
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) GetFollowerFor(id, actor string) (*Follower, int) {
|
||||
ap.RLock()
|
||||
defer ap.RUnlock()
|
||||
|
||||
return ap.getFollowerFor(id, actor)
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) IsFollowing(id, actor string) bool {
|
||||
_, idx := ap.GetFollowerFor(id, actor)
|
||||
return idx != -1
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) delFollower(id string, idx int) {
|
||||
followers := ap.followers[id]
|
||||
ap.followers[id] = append(followers[:idx], followers[idx+1:]...)
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) DelFollower(id string, idx int) {
|
||||
ap.Lock()
|
||||
defer ap.Unlock()
|
||||
|
||||
ap.delFollower(id, idx)
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) Broadcast(actor *Actor, obj any) {
|
||||
ap.RLock()
|
||||
defer ap.RUnlock()
|
||||
|
||||
followers, ok := ap.followers[actor.ID]
|
||||
if !ok {
|
||||
log.Debugf("no followers found for %s", actor.ID)
|
||||
return
|
||||
}
|
||||
log.Debugf("%d followers found for %s", len(followers), actor.ID)
|
||||
|
||||
for _, follower := range followers {
|
||||
ap.outbox <- &activity{actor: actor, inbox: follower.Inbox, object: obj}
|
||||
}
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) Stats() (stats Stats) {
|
||||
ap.RLock()
|
||||
defer ap.RUnlock()
|
||||
|
||||
stats.Actors = len(ap.followers)
|
||||
|
||||
for _, followers := range ap.followers {
|
||||
stats.Followers += len(followers)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) DebugEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
ap.RLock()
|
||||
defer ap.RUnlock()
|
||||
|
||||
doc := struct {
|
||||
Followers map[string]Followers
|
||||
}{
|
||||
Followers: ap.followers,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) Endpoint(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("ap.Endpoint(():")
|
||||
|
||||
dump, _ := httputil.DumpRequest(r, true)
|
||||
fmt.Fprintf(os.Stderr, "Incoming Request:\n %q\n", dump)
|
||||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error reading request body")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
obj, err := ParseObject(data)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error parsing object")
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch obj.(type) {
|
||||
case *Follow:
|
||||
ap.inbox <- obj.(*Follow)
|
||||
http.Error(w, "Accepted", http.StatusAccepted)
|
||||
default:
|
||||
log.Debugf("unsupported object type %T", obj)
|
||||
http.Error(w, "Unsupported", http.StatusUnprocessableEntity)
|
||||
}
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) handleFollow(follow *Follow) {
|
||||
log.Debugf("Handling Follow: %#v", follow)
|
||||
|
||||
if !ap.IsFollowing(follow.Object, follow.Actor) {
|
||||
follower := NewFollower(follow.Actor)
|
||||
ap.AddFollower(follow.Object, follower)
|
||||
|
||||
actor, err := LookupActor(ap.actor, follow.Actor)
|
||||
if err != nil {
|
||||
log.WithError(err).Errorf("error looking up actor %s", follow.Actor)
|
||||
return
|
||||
}
|
||||
|
||||
follower.Inbox = actor.Inbox
|
||||
|
||||
accept := AcceptFollow(follow.ID, follow.To, follow)
|
||||
ap.outbox <- &activity{actor: ap.actor, inbox: actor.Inbox, object: accept}
|
||||
}
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) processInbox() {
|
||||
obj := <-ap.inbox
|
||||
|
||||
switch obj.(type) {
|
||||
case *Follow:
|
||||
ap.handleFollow(obj.(*Follow))
|
||||
default:
|
||||
log.Warnf("unsupported object type %T", obj)
|
||||
}
|
||||
}
|
||||
|
||||
func (ap *ActivityPub) processOutbox() {
|
||||
activity := <-ap.outbox
|
||||
activity.attempts++
|
||||
|
||||
if activity.attempts > defaultRedeliveryAttempts {
|
||||
log.Errorf(
|
||||
"giving up processing activity from actor=%s to inbox=%s for object=%T after %d attempts",
|
||||
activity.actor, activity.inbox, activity.object, activity.attempts,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(activity.object)
|
||||
if err != nil {
|
||||
log.WithError(err).Errorf("error serializing object=%T", activity.object)
|
||||
return
|
||||
}
|
||||
buf := bytes.NewBuffer(data)
|
||||
|
||||
res, err := SignedRequest(activity.actor, http.MethodPost, activity.inbox, buf)
|
||||
if err != nil {
|
||||
log.WithError(err).Errorf(
|
||||
"error sending activity actor=%s inbox=%s object=%T",
|
||||
activity.actor, activity.inbox, activity.object,
|
||||
)
|
||||
// Attempt re-delivery
|
||||
ap.outbox <- activity
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode/100 != 2 {
|
||||
log.Errorf(
|
||||
"non-200 response ending activity actor=%s inbox=%s object=%T status=%s",
|
||||
activity.actor, activity.inbox, activity.object, res.Status,
|
||||
)
|
||||
// Attempt re-delivery
|
||||
ap.outbox <- activity
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf(
|
||||
"successfully sent activity object=%T to inbox=%s from actor=%s",
|
||||
activity.object, activity.inbox, activity.object,
|
||||
)
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-fed/httpsig"
|
||||
)
|
||||
|
||||
func signRequest(privateKey crypto.PrivateKey, pubKeyId string, r *http.Request) error {
|
||||
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||
digestAlgorithm := httpsig.DigestSha256
|
||||
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
|
||||
headersToSign := []string{httpsig.RequestTarget, "date", "digest"}
|
||||
signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 30)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return signer.SignRequest(privateKey, pubKeyId, r, nil)
|
||||
}
|
||||
|
||||
// SignedRequest performs a signed request using the Actor's private key
|
||||
func SignedRequest(actor *Actor, method, uri string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequest(method, uri, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating request: %w", err)
|
||||
|
||||
}
|
||||
|
||||
req.Header.Add("Digest", emptyDigest)
|
||||
req.Header.Add("Date", time.Now().Format(http.TimeFormat))
|
||||
req.Header.Add("Accept", ActivityContentType)
|
||||
if method == http.MethodPost {
|
||||
req.Header.Add("Content-Type", ActivityContentType)
|
||||
}
|
||||
|
||||
if err := signRequest(actor.key, actor.PublicKey.ID, req); err != nil {
|
||||
return nil, fmt.Errorf("error signing request: %w", err)
|
||||
}
|
||||
|
||||
dump, _ := httputil.DumpRequestOut(req, true)
|
||||
fmt.Fprintf(os.Stderr, "Outgoing Request:\n %q\n", dump)
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making request: %w", err)
|
||||
}
|
||||
|
||||
dump, _ = httputil.DumpResponse(res, true)
|
||||
fmt.Fprintf(os.Stderr, "Received Response:\n %q\n", dump)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// LookupActor looks up an actor by its URI and returns an Actor object
|
||||
func LookupActor(actor *Actor, uri string) (*Actor, error) {
|
||||
res, err := SignedRequest(actor, http.MethodGet, uri, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error looking up actor %s: %w", uri, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading response body: %w", err)
|
||||
}
|
||||
|
||||
var a Actor
|
||||
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return nil, fmt.Errorf("error parsing response body: %w", err)
|
||||
}
|
||||
|
||||
return &a, nil
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
// Copyright 2020-present Yarn.social
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const defaultKeySize = 2048
|
||||
|
||||
// SetKey sets private key of this actor and encodes the public key
|
||||
func (a *Actor) SetKey(key *rsa.PrivateKey) error {
|
||||
a.key = key
|
||||
|
||||
publicKey, err := EncodePublicKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error encoding public key: %w", err)
|
||||
}
|
||||
|
||||
a.PublicKey = PublicKey{
|
||||
ID: a.ID,
|
||||
Owner: a.ID,
|
||||
PublicKey: publicKey,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateKeys generates a private rsa key
|
||||
func GenerateKeys(bits int) (*rsa.PrivateKey, error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, bits)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generating keys: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GetOrCreateKey reads the private rsa key from a file or creates a new one if it doesn't exist
|
||||
func GetOrCreateKey(fn string) (key *rsa.PrivateKey, err error) {
|
||||
key, err = ReadKey(fn)
|
||||
if err != nil {
|
||||
key, err = GenerateKeys(defaultKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generating new key: %s", err)
|
||||
}
|
||||
|
||||
if err := WriteKey(fn, key); err != nil {
|
||||
return nil, fmt.Errorf("error writing new key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ReadKey reads the private rsa key from a file
|
||||
func ReadKey(fn string) (*rsa.PrivateKey, error) {
|
||||
pemData, err := os.ReadFile(fn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading private key %s: %w", fn, err)
|
||||
}
|
||||
|
||||
pemBlock, _ := pem.Decode(pemData)
|
||||
if pemBlock == nil {
|
||||
return nil, fmt.Errorf("error decoding pem")
|
||||
}
|
||||
|
||||
key, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// WriteKey writes the private rsa key to the file
|
||||
func WriteKey(fn string, key *rsa.PrivateKey) error {
|
||||
p := filepath.Dir(fn)
|
||||
if err := os.MkdirAll(p, os.FileMode(0755)); err != nil {
|
||||
return fmt.Errorf("error creating path %s: %w", p, err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(fn, os.O_CREATE|os.O_EXCL|os.O_WRONLY, os.FileMode(0600))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error opening %s for writing: %w", fn, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var privateKey = &pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}
|
||||
|
||||
return pem.Encode(f, privateKey)
|
||||
}
|
||||
|
||||
// EncodePublicKey encodes the public key of a private rsa key
|
||||
func EncodePublicKey(key *rsa.PrivateKey) (string, error) {
|
||||
pkixBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error encoding public key: %w", err)
|
||||
}
|
||||
|
||||
var publicKey = &pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: pkixBytes,
|
||||
}
|
||||
|
||||
return string(pem.EncodeToMemory(publicKey)), nil
|
||||
}
|
@ -0,0 +1,211 @@
|
||||
// Copyright 2020-present Yarn.social
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ActivityContentType is the content type for all ActivityPub activities
|
||||
ActivityContentType = "application/activity+json"
|
||||
|
||||
// LinkedDataContentType is the JSON Linked Data type
|
||||
LinkedDataContentType = "application/ld+json"
|
||||
|
||||
// ActivityBaseURI the URI for the ActivityStreams namespace
|
||||
ActivityBaseURI = "https://www.w3.org/ns/activitystreams"
|
||||
|
||||
// SecurityContextURI the URI for the security namespace (for an Actor's PublicKey)
|
||||
SecurityContextURI = "https://w3id.org/security/v1"
|
||||
|
||||
// PublicNS is the reference to the Public entity in the ActivityStreams namespace.
|
||||
PublicNS = ActivityBaseURI + "#Public"
|
||||
|
||||
defaultIconMediaType = "image/png"
|
||||
personType = "Person"
|
||||
serviceType = "Service"
|
||||
createType = "Create"
|
||||
acceptType = "Accept"
|
||||
followType = "Follow"
|
||||
noteType = "Note"
|
||||
imageType = "Image"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnsupportedObject is the error returned when parsing objects that are not supported
|
||||
ErrUnsupportedObject = errors.New("error: unsupported object")
|
||||
)
|
||||
|
||||
// DefaultContext is a slice of URIs that form the default context for all objects
|
||||
var DefaultContext = []string{
|
||||
ActivityBaseURI,
|
||||
SecurityContextURI,
|
||||
}
|
||||
|
||||
// Follow is a Follow activity
|
||||
type Follow struct {
|
||||
Context any `json:"@context"`
|
||||
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
|
||||
Actor string `json:"actor"`
|
||||
Object string `json:"object"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// Accept is an Accept activity
|
||||
type Accept struct {
|
||||
Context any `json:"@context"`
|
||||
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
|
||||
Actor string `json:"actor"`
|
||||
Object any `json:"object"`
|
||||
}
|
||||
|
||||
// AcceptFollow creates a new ActivityPub Accept Activity with an embedded Follow object
|
||||
func AcceptFollow(id, actor string, follow *Follow) *Accept {
|
||||
return &Accept{
|
||||
Context: DefaultContext,
|
||||
|
||||
ID: id,
|
||||
Type: acceptType,
|
||||
|
||||
Actor: actor,
|
||||
|
||||
Object: follow,
|
||||
}
|
||||
}
|
||||
|
||||
// Note is a Note object type embedded in an Create Activity
|
||||
type Note struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
|
||||
To string `json:"to"`
|
||||
Content string `json:"content"`
|
||||
Published time.Time `json:"published"`
|
||||
AttributeTo string `json:"attributedTo"`
|
||||
InReplyTo string `json:"inReplyTo"`
|
||||
}
|
||||
|
||||
// Create is a Create activity
|
||||
type Create struct {
|
||||
Context any `json:"@context"`
|
||||
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
|
||||
Actor string `json:"actor"`
|
||||
Object any `json:"object"`
|
||||
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// CreateNote creates a new ActivityPub Create Activity with an embedded Note object
|
||||
func CreateNote(id, actor string, created time.Time, content string) *Create {
|
||||
return &Create{
|
||||
Context: DefaultContext,
|
||||
|
||||
ID: id,
|
||||
Type: createType,
|
||||
|
||||
Actor: actor,
|
||||
|
||||
Object: Note{
|
||||
ID: id,
|
||||
Type: noteType,
|
||||
|
||||
To: PublicNS,
|
||||
Content: content,
|
||||
Published: created,
|
||||
AttributeTo: actor,
|
||||
//TODO Add support for replies
|
||||
},
|
||||
|
||||
To: PublicNS,
|
||||
}
|
||||
}
|
||||
|
||||
// PublicKey is a a Person's Public RSA Key
|
||||
type PublicKey struct {
|
||||
ID string `json:"id"`
|
||||
Owner string `json:"owner"`
|
||||
PublicKey string `json:"publicKeyPem"`
|
||||
}
|
||||
|
||||
// Icon is an Icon
|
||||
type Icon struct {
|
||||
Type string `json:"type"`
|
||||
MediaType string `json:"mediaType"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// Actor is a basic ActivityPub Actor
|
||||
type Actor struct {
|
||||
Context any `json:"@context"`
|
||||
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
|
||||
Icon Icon `json:"icon"`
|
||||
Summary string `json:"summary"`
|
||||
PreferredUsername string `json:"preferredUsername"`
|
||||
|
||||
Inbox string `json:"inbox"`
|
||||
Outbox string `json:"outbox"`
|
||||
Following string `json:"following"`
|
||||
Followers string `json:"followers"`
|
||||
PublicKey PublicKey `json:"publicKey"`
|
||||
|
||||
key *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// String implements the fmt.Stringer interface
|
||||
func (a *Actor) String() string {
|
||||
return fmt.Sprintf("Actor{%s}", a.ID)
|
||||
}
|
||||
|
||||
// SetIcon sets the Actor's Icon (Avatar)
|
||||
func (a *Actor) SetIcon(url string) {
|
||||
a.Icon = Icon{Type: imageType, MediaType: defaultIconMediaType, URL: url}
|
||||
}
|
||||
|
||||
// NewService creates a new ActivityPub Actor of Type Service
|
||||
func NewService(id string) *Actor {
|
||||
return &Actor{Context: DefaultContext, ID: id, Type: serviceType}
|
||||
}
|
||||
|
||||
// NewPerson creates a new ActivityPub Actor of Type Person
|
||||
func NewPerson(id string) *Actor {
|
||||
return &Actor{Context: DefaultContext, ID: id, Type: personType}
|
||||
}
|
||||
|
||||
// ParseObject parses the JSON object into an ActivityPub activity object
|
||||
// by first inspecting its type before un-marshalling into the correct type.
|
||||
func ParseObject(data []byte) (v any, err error) {
|
||||
var obj map[string]any
|
||||
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return nil, fmt.Errorf("error parsing object: %w", err)
|
||||
}
|
||||
|
||||
switch objType := obj["type"].(string); objType {
|
||||
case followType:
|
||||
var follow Follow
|
||||
if err := json.Unmarshal(data, &follow); err != nil {
|
||||
return nil, fmt.Errorf("error parsing follow object: %w", err)
|
||||
}
|
||||
return &follow, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w %q", ErrUnsupportedObject, objType)
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
// Copyright 2020-present Yarn.social
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package activitypub_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tj/assert"
|
||||
|
||||
"git.mills.io/yarnsocial/yarn/internal/activitypub"
|
||||
)
|
||||
|
||||
func TestParseObject(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
|
||||
payload := []byte(`{"@context":"https://www.w3.org/ns/activitystreams","actor":"https://gotosocial.mills.io/users/prologic","id":"https://gotosocial.mills.io/users/prologic/follow/01NQVA90B14DS9KY139N1XT4MT","object":"https://yarn.mills.io/user/james/","to":"https://yarn.mills.io/user/james/","type":"Follow"}`)
|
||||
obj, err := activitypub.ParseObject(payload)
|
||||
require.NoError(err)
|
||||
|
||||
follow, ok := obj.(*activitypub.Follow)
|
||||
require.True(ok)
|
||||
assert.Equal(follow.Type, "Follow")
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// InboxHandler ...
|
||||
func (s *Server) InboxHandler() httprouter.Handle {
|
||||
isAdminUser := IsAdminUserFactory(s.config)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024)
|
||||
defer r.Body.Close()
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
ap.Endpoint(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
ctx := NewContext(s, r)
|
||||
|
||||
if !isAdminUser(ctx.User) {
|
||||
ctx.Error = true
|
||||
ctx.Message = "You are not a Pod Owner!"
|
||||
s.render("403", w, ctx)
|
||||
return
|
||||
}
|
||||
|
||||
ap.DebugEndpoint(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// OutboxHandler ...
|
||||
func (s *Server) OutboxHandler() httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024)
|
||||
defer r.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error reading request body")
|
||||
http.Error(w, "Internal Server Error", http.StatusInsufficientStorage)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Outbox Request:\n %s\n", string(data))
|
||||
|
||||
http.Error(w, "Not Implemented", http.StatusNotImplemented)
|
||||
}
|
||||
}
|