Activity Pub Implementation #58

Merged
h4kor merged 13 commits from activity_pub into main 2024-05-18 14:31:11 +00:00
5 changed files with 193 additions and 118 deletions
Showing only changes of commit 3cbf952ae6 - Show all commits

View File

@ -1,10 +1,12 @@
package app
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"io"
"log/slog"
"net/http"
@ -17,6 +19,7 @@ import (
"time"
vocab "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"github.com/go-fed/httpsig"
)
@ -42,7 +45,10 @@ func (cfg *ActivityPubConfig) ParseFormData(data model.HttpFormData, binSvc mode
func (cfg *ActivityPubConfig) PrivateKey() *rsa.PrivateKey {
block, _ := pem.Decode([]byte(cfg.PrivateKeyPem))
privKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
slog.Error("error x509.ParsePKCS1PrivateKey", "err", err)
}
return privKey
}
@ -131,7 +137,13 @@ func (s *ActivityPubService) sign(privateKey *rsa.PrivateKey, pubKeyId string, b
return err
}
func (s *ActivityPubService) GetActor(reqUrl string, fromGame string) (vocab.Actor, error) {
func (s *ActivityPubService) GetActor(reqUrl string) (vocab.Actor, error) {
siteConfig := model.SiteConfig{}
apConfig := ActivityPubConfig{}
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
c := http.Client{}
parsedUrl, err := url.Parse(reqUrl)
@ -145,11 +157,6 @@ func (s *ActivityPubService) GetActor(reqUrl string, fromGame string) (vocab.Act
req.Header.Set("Date", time.Now().Format(http.TimeFormat))
req.Header.Set("Host", parsedUrl.Host)
siteConfig := model.SiteConfig{}
apConfig := ActivityPubConfig{}
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
err = s.sign(apConfig.PrivateKey(), siteConfig.FullUrl+"/activitypub/actor#main-key", nil, req)
if err != nil {
slog.Error("Signing error", "err", err)
@ -188,7 +195,9 @@ func (s *ActivityPubService) VerifySignature(r *http.Request, sender string) err
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
actor, err := s.GetActor(sender, siteConfig.FullUrl+"/activitypub/actor")
slog.Info("verifying for", "sender", sender, "retriever", siteConfig.FullUrl+"/activitypub/actor")
actor, err := s.GetActor(sender)
// actor does not have a pub key -> don't verify
if actor.PublicKey.PublicKeyPem == "" {
return nil
@ -213,3 +222,67 @@ func (s *ActivityPubService) VerifySignature(r *http.Request, sender string) err
}
return verifier.Verify(pubKey, httpsig.RSA_SHA256)
}
func (s *ActivityPubService) Accept(act *vocab.Activity) error {
actor, err := s.GetActor(act.Actor.GetID().String())
if err != nil {
return err
}
accept := vocab.AcceptNew(vocab.IRI("TODO"), act)
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
).Marshal(accept)
if err != nil {
slog.Error("marshalling error", "err", err)
return err
}
return s.sendObject(actor, data)
}
func (s *ActivityPubService) sendObject(to vocab.Actor, data []byte) error {
siteConfig := model.SiteConfig{}
apConfig := ActivityPubConfig{}
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
if to.Inbox == nil {
slog.Error("actor has no inbox", "actor", to)
return errors.New("actor has no inbox")
}
actorUrl, err := url.Parse(to.Inbox.GetID().String())
if err != nil {
slog.Error("parse error", "err", err)
return err
}
c := http.Client{}
req, _ := http.NewRequest("POST", to.Inbox.GetID().String(), bytes.NewReader(data))
req.Header.Set("Accept", "application/ld+json")
req.Header.Set("Date", time.Now().Format(http.TimeFormat))
req.Header.Set("Host", actorUrl.Host)
err = s.sign(apConfig.PrivateKey(), siteConfig.FullUrl+"/activitypub/actor#main-key", data, req)
if err != nil {
slog.Error("Signing error", "err", err)
return err
}
resp, err := c.Do(req)
if err != nil {
slog.Error("Sending error", "url", req.URL, "err", err)
return err
}
slog.Info("Request", "host", resp.Request.Header)
if resp.StatusCode > 299 {
body, _ := io.ReadAll(resp.Body)
slog.Error("Error sending Note", "method", resp.Request.Method, "url", resp.Request.URL, "status", resp.Status, "body", string(body))
return err
}
body, _ := io.ReadAll(resp.Body)
slog.Info("Sent Body", "body", string(data))
slog.Info("Retrieved", "status", resp.Status, "body", string(body))
return nil
}

View File

@ -52,23 +52,23 @@ def webfinger():
{
"subject": "acct:h4kor@mock_masto",
"aliases": [
"http://mock_masto/@h4kor",
"http://mock_masto/users/h4kor",
"http://mock_masto:8000/@h4kor",
"http://mock_masto:8000/users/h4kor",
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "http://mock_masto/@h4kor",
"href": "http://mock_masto:8000/@h4kor",
},
{
"rel": "self",
"type": "application/activity+json",
"href": "http://mock_masto/users/h4kor",
"href": "http://mock_masto:8000/users/h4kor",
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "http://mock_masto/authorize_interaction?uri={uri}",
"template": "http://mock_masto:8000/authorize_interaction?uri={uri}",
},
{
"rel": "http://webfinger.net/rel/avatar",
@ -120,53 +120,53 @@ def actor():
"focalPoint": {"@container": "@list", "@id": "toot:focalPoint"},
},
],
"id": "http://mock_masto/users/h4kor",
"id": "http://mock_masto:8000/users/h4kor",
"type": "Person",
"following": "http://mock_masto/users/h4kor/following",
"followers": "http://mock_masto/users/h4kor/followers",
"inbox": "http://mock_masto/users/h4kor/inbox",
"outbox": "http://mock_masto/users/h4kor/outbox",
"featured": "http://mock_masto/users/h4kor/collections/featured",
"featuredTags": "http://mock_masto/users/h4kor/collections/tags",
"following": "http://mock_masto:8000/users/h4kor/following",
"followers": "http://mock_masto:8000/users/h4kor/followers",
"inbox": "http://mock_masto:8000/users/h4kor/inbox",
"outbox": "http://mock_masto:8000/users/h4kor/outbox",
"featured": "http://mock_masto:8000/users/h4kor/collections/featured",
"featuredTags": "http://mock_masto:8000/users/h4kor/collections/tags",
"preferredUsername": "h4kor",
"name": "Niko",
"summary": '<p>Teaching computers to do things with arguable efficiency.</p><p>he/him</p><p><a href="http://mock_masto/tags/vegan" class="mention hashtag" rel="tag">#<span>vegan</span></a> <a href="http://mock_masto/tags/cooking" class="mention hashtag" rel="tag">#<span>cooking</span></a> <a href="http://mock_masto/tags/programming" class="mention hashtag" rel="tag">#<span>programming</span></a> <a href="http://mock_masto/tags/politics" class="mention hashtag" rel="tag">#<span>politics</span></a> <a href="http://mock_masto/tags/climate" class="mention hashtag" rel="tag">#<span>climate</span></a></p>',
"url": "http://mock_masto/@h4kor",
"summary": '<p>Teaching computers to do things with arguable efficiency.</p><p>he/him</p><p><a href="http://mock_masto:8000/tags/vegan" class="mention hashtag" rel="tag">#<span>vegan</span></a> <a href="http://mock_masto:8000/tags/cooking" class="mention hashtag" rel="tag">#<span>cooking</span></a> <a href="http://mock_masto:8000/tags/programming" class="mention hashtag" rel="tag">#<span>programming</span></a> <a href="http://mock_masto:8000/tags/politics" class="mention hashtag" rel="tag">#<span>politics</span></a> <a href="http://mock_masto:8000/tags/climate" class="mention hashtag" rel="tag">#<span>climate</span></a></p>',
"url": "http://mock_masto:8000/@h4kor",
"manuallyApprovesFollowers": False,
"discoverable": True,
"indexable": False,
"published": "2018-08-16T00:00:00Z",
"memorial": False,
"devices": "http://mock_masto/users/h4kor/collections/devices",
"devices": "http://mock_masto:8000/users/h4kor/collections/devices",
"publicKey": {
"id": "http://mock_masto/users/h4kor#main-key",
"owner": "http://mock_masto/users/h4kor",
"id": "http://mock_masto:8000/users/h4kor#main-key",
"owner": "http://mock_masto:8000/users/h4kor",
"publicKeyPem": PUB_KEY_PEM,
},
"tag": [
{
"type": "Hashtag",
"href": "http://mock_masto/tags/politics",
"href": "http://mock_masto:8000/tags/politics",
"name": "#politics",
},
{
"type": "Hashtag",
"href": "http://mock_masto/tags/climate",
"href": "http://mock_masto:8000/tags/climate",
"name": "#climate",
},
{
"type": "Hashtag",
"href": "http://mock_masto/tags/vegan",
"href": "http://mock_masto:8000/tags/vegan",
"name": "#vegan",
},
{
"type": "Hashtag",
"href": "http://mock_masto/tags/programming",
"href": "http://mock_masto:8000/tags/programming",
"name": "#programming",
},
{
"type": "Hashtag",
"href": "http://mock_masto/tags/cooking",
"href": "http://mock_masto:8000/tags/cooking",
"name": "#cooking",
},
],
@ -188,7 +188,7 @@ def actor():
"value": '<a href="http://git.libove.org/h4kor/owl-blogs" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">git.libove.org/h4kor/owl-blogs</span><span class="invisible"></span></a>',
},
],
"endpoints": {"sharedInbox": "http://mock_masto/inbox"},
"endpoints": {"sharedInbox": "http://mock_masto:8000/inbox"},
"icon": {
"type": "Image",
"mediaType": "image/png",
@ -198,12 +198,17 @@ def actor():
)
@app.route("/users/h4kor/inbox")
@app.route("/users/h4kor/inbox", methods=["POST"])
def inbox():
if request.method == "POST":
INBOX.append(request.get_json())
INBOX.append(json.loads(request.get_data()))
return ""
@app.route("/msgs")
def msgs():
return json.dumps(INBOX)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port="8000")

View File

@ -1,7 +1,13 @@
from contextlib import contextmanager
from datetime import datetime, timezone
import json
from time import sleep
from urllib.parse import urlparse
import requests, base64, hashlib
from urllib.parse import urlparse
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
ACCT_NAME = "acct:blog@localhost:3000"
@ -36,49 +42,23 @@ K98rXSX3VvY4w48AznvPMKVLqesFjcvwnBdvk/NqXod20CMSpOEVj6W/nGoTBQt2
def ensure_follow(client, inbox_url, actor_url):
resp = client.post(
req = sign(
"POST",
inbox_url,
json={
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mock_masto/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
headers={"Content-Type": "application/activity+json"},
)
resp = requests.Session().send(req)
assert resp.status_code == 200
def get_gmt_now() -> str:
return datetime.now(datetime.UTC).strftime("%a, %d %b %Y %H:%M:%S GMT")
from http_message_signatures import (
HTTPMessageSigner,
HTTPMessageVerifier,
HTTPSignatureKeyResolver,
algorithms,
)
import requests, base64, hashlib, http_sfv
class MyHTTPSignatureKeyResolver(HTTPSignatureKeyResolver):
keys = {"my-key": b"top-secret-key"}
def resolve_public_key(self, key_id: str):
return self.keys[key_id]
def resolve_private_key(self, key_id: str):
return priv_key
# return PRIV_KEY_PEM
def sign(method, url, data):
from urllib.parse import urlparse
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
priv_key = load_pem_private_key(PRIV_KEY_PEM.encode(), None)
body = json.dumps(data).encode()
@ -103,6 +83,20 @@ date: {date}""".encode()
request.headers["Host"] = host
request.headers["Date"] = date
request.headers["Signature"] = (
f'keyId="http://mock_masto/users/h4kor#main-key",headers="(request-target) host date",signature="{sig_str}"'
f'keyId="http://mock_masto:8000/users/h4kor#main-key",headers="(request-target) host date",signature="{sig_str}"'
)
return request
@contextmanager
def msg_inc(n):
resp = requests.get("http://localhost:8000/msgs")
data = resp.json()
msgs = len(data)
yield
sleep(0.2)
resp = requests.get("http://localhost:8000/msgs")
data = resp.json()
assert msgs + n == len(
data
), f"prev: {msgs}, now: {len(data)}, expected: {msgs + n}"

View File

@ -1,6 +1,7 @@
from pprint import pprint
from time import sleep
import requests
from .fixtures import ensure_follow, sign
from .fixtures import ensure_follow, msg_inc, sign
import pytest
@ -28,57 +29,59 @@ def test_actor(client, actor_url):
def test_following(client, inbox_url, followers_url, actor_url):
req = sign(
"POST",
inbox_url,
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mock_masto/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
)
pprint(req.headers)
resp = requests.Session().send(req)
with msg_inc(1):
req = sign(
"POST",
inbox_url,
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
)
resp = requests.Session().send(req)
assert resp.status_code == 200
assert resp.status_code == 200
resp = client.get(
followers_url, headers={"Content-Type": "application/activity+json"}
)
assert resp.status_code == 200
data = resp.json()
pprint(data)
assert "items" in data
assert len(data["items"]) == 1
resp = client.get(
followers_url, headers={"Content-Type": "application/activity+json"}
)
assert resp.status_code == 200
data = resp.json()
pprint(data)
assert "items" in data
assert len(data["items"]) == 1
# def test_unfollow(client, inbox_url, followers_url, actor_url):
# ensure_follow(client, inbox_url, actor_url)
def test_unfollow(client, inbox_url, followers_url, actor_url):
ensure_follow(client, inbox_url, actor_url)
with msg_inc(1):
req = sign(
"POST",
inbox_url,
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://mock_masto:8000/users/h4kor#follows/3632040/undo",
"type": "Undo",
"actor": "http://mock_masto:8000/users/h4kor",
"object": {
"id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
},
)
resp = requests.Session().send(req)
assert resp.status_code == 200
# resp = client.post(
# inbox_url,
# json={
# "@context": "https://www.w3.org/ns/activitystreams",
# "id": "http://mock_masto:8000/users/h4kor#follows/3632040/undo",
# "type": "Undo",
# "actor": "http://mock_masto:8000/users/h4kor",
# "object": {
# "id": "https://mock_masto/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
# "type": "Follow",
# "actor": "http://mock_masto:8000/users/h4kor",
# "object": actor_url,
# },
# },
# headers={"Content-Type": "application/activity+json"},
# )
# assert resp.status_code == 200
# resp = client.get(
# followers_url, headers={"Content-Type": "application/activity+json"}
# )
# assert resp.status_code == 200
# data = resp.json()
# assert "items" in data
# assert len(data["items"]) == 0
resp = client.get(
followers_url, headers={"Content-Type": "application/activity+json"}
)
assert resp.status_code == 200
data = resp.json()
pprint(data)
assert "totalItems" in data
assert data["totalItems"] == 0

View File

@ -149,7 +149,7 @@ func (s *ActivityPubServer) processFollow(r *http.Request, act *vocab.Activity)
return err
}
// go acpub.Accept(gameName, act)
go s.apService.Accept(act)
return nil
}