Skip to content

Commit 9279f22

Browse files
committed
Add direct example
1 parent 6bbd69c commit 9279f22

File tree

5 files changed

+356
-0
lines changed

5 files changed

+356
-0
lines changed

example/README.md

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# ActivityPub Example
2+
3+
Here is a less wordy example which will let you see some results immediately.
4+
5+
1. Set up a server using Let's Encrypt to get your HTTPS certificates.
6+
2. Turn off any web servers you may have used, e.g. nginx/apache/caddy
7+
3. Generate a public/private key pair for your user
8+
```sh
9+
openssl genrsa -out private.pem 2048
10+
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
11+
```
12+
4. Set up Python environment
13+
```sh
14+
python3 -m venv env
15+
source env/bin/activate
16+
pip install -r requirements.txt
17+
```
18+
5. Enter your own details in config.py
19+
6. Run the server in one shell `python server.py`
20+
a. This may need to run as root as it binds port 443
21+
b. Confirm by searching your user on Mastodon, e.g. `@user@host.com` and note what response you receive
22+
7. Run `python follow_user.py` and `python follow_user.py --unfollow`
23+
a. The server must be running for Mastodon to send an accept message.

example/config.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Limit yourself to one test user otherwise every username will be valid
2+
# and Mastodon will cache these until you provide it with a Delete action
3+
HOSTNAME = "example.com"
4+
USER = "johnny"
5+
6+
# Ideally use a user account you own on a working ActivityPub instance
7+
TEST_HOSTNAME = "example.com" # mastodon.social
8+
TEST_USER = "zampano"

example/follow_user.py

+220
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
from cryptography.hazmat.backends import default_backend as crypto_default_backend
2+
from cryptography.hazmat.primitives import serialization as crypto_serialization
3+
from cryptography.hazmat.primitives import hashes
4+
from cryptography.hazmat.primitives.asymmetric import padding
5+
6+
from urllib.parse import urlparse
7+
import base64
8+
import datetime
9+
import requests
10+
import json
11+
import hashlib
12+
import sys
13+
from config import HOSTNAME, USER, TEST_HOSTNAME, TEST_USER
14+
15+
16+
OWNED_URI = f"https://{HOSTNAME}"
17+
OWNED_USER = USER
18+
19+
sender_url = f"{OWNED_URI}/users/{OWNED_USER}"
20+
sender_key = f"{OWNED_URI}/users/{OWNED_USER}#main-key"
21+
# NOTE: This id should be unique for each action
22+
activity_id = f"{OWNED_URI}/users/{OWNED_USER}/follows/test"
23+
24+
TEST_URI = f"https://{TEST_HOSTNAME}"
25+
26+
27+
def get_resource_from_webfinger(uri, resource):
28+
print("Getting recipient url from .well-known/webfinger")
29+
webfinger = requests.get(
30+
f"{uri}/.well-known/webfinger?resource={resource}",
31+
headers={"Accept": "application/jrd+json, application/json"},
32+
)
33+
return json.loads(webfinger.content)
34+
35+
36+
def get_canonical_from_webfinger(uri, resource):
37+
webfinger_json = get_resource_from_webfinger(uri, resource)
38+
39+
if "links" not in webfinger_json:
40+
print("No links in webfinger")
41+
return None
42+
43+
self_uri = [
44+
link["href"] for link in webfinger_json["links"] if link["rel"] == "self"
45+
]
46+
if len(self_uri) == 0:
47+
print("No self uri")
48+
return None
49+
50+
return self_uri[0]
51+
52+
53+
def get_inbox_from_canonical_user(uri, resource):
54+
recipient_url = get_canonical_from_webfinger(uri, resource)
55+
56+
user = requests.get(
57+
recipient_url, headers={"Accept": "application/activity+json, application/json"}
58+
)
59+
user_json = json.loads(user.content)
60+
61+
if "inbox" not in user_json:
62+
print("No inbox")
63+
return None
64+
65+
return user_json["inbox"]
66+
67+
68+
def sign(text):
69+
# The following is to sign the HTTP request as defined in HTTP Signatures.
70+
private_key_text = open("private.pem", "rb").read() # load from file
71+
72+
private_key = crypto_serialization.load_pem_private_key(
73+
private_key_text, password=None, backend=crypto_default_backend()
74+
)
75+
return private_key.sign(text, padding.PKCS1v15(), hashes.SHA256())
76+
77+
78+
def follow():
79+
recipient_url = get_canonical_from_webfinger(
80+
TEST_URI, f"acct:{TEST_USER}@{TEST_HOSTNAME}"
81+
)
82+
recipient_inbox = get_inbox_from_canonical_user(
83+
TEST_URI, f"acct:{TEST_USER}@{TEST_HOSTNAME}"
84+
)
85+
86+
print(f"Sending follow request from {sender_url} to {recipient_inbox}")
87+
88+
recipient_parsed = urlparse(recipient_inbox)
89+
recipient_host = recipient_parsed.netloc
90+
recipient_path = recipient_parsed.path
91+
92+
follow_request_message = {
93+
"@context": "https://www.w3.org/ns/activitystreams",
94+
"id": activity_id,
95+
"type": "Follow",
96+
"actor": sender_url,
97+
"object": recipient_url,
98+
}
99+
100+
digest = base64.b64encode(
101+
hashlib.sha256(json.dumps(follow_request_message).encode("utf-8")).digest()
102+
)
103+
104+
current_date = datetime.datetime.now(datetime.timezone.utc).strftime(
105+
"%a, %d %b %Y %H:%M:%S GMT"
106+
)
107+
108+
# signature_text = generate_signing_text
109+
signature_text: bytes = b"(request-target): post " + recipient_path.encode("utf-8")
110+
signature_text += b"\nhost: " + recipient_host.encode("utf-8")
111+
signature_text += b"\ndate: " + current_date.encode("utf-8")
112+
signature_text += b"\ndigest: SHA-256=" + digest
113+
114+
raw_signature = sign(signature_text)
115+
116+
signature_text_b64 = base64.b64encode(raw_signature).decode("utf-8")
117+
signature_header = (
118+
'keyId="'
119+
+ sender_key
120+
+ '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="'
121+
+ signature_text_b64
122+
+ '"'
123+
)
124+
125+
headers = {
126+
"Date": current_date,
127+
"Content-Type": "application/activity+json",
128+
"Host": recipient_host,
129+
"Digest": "SHA-256=" + digest.decode("utf-8"),
130+
"Signature": signature_header,
131+
}
132+
print(headers)
133+
134+
# Now that the header is set up, we will construct the message
135+
r = requests.post(recipient_inbox, headers=headers, json=follow_request_message)
136+
print(r)
137+
print(r.content)
138+
139+
140+
def unfollow():
141+
recipient_url = get_canonical_from_webfinger(
142+
TEST_URI, f"acct:{TEST_USER}@{TEST_HOSTNAME}"
143+
)
144+
recipient_inbox = get_inbox_from_canonical_user(
145+
TEST_URI, f"acct:{TEST_USER}@{TEST_HOSTNAME}"
146+
)
147+
148+
print(f"Sending unfollow request from {sender_url} to {recipient_inbox}")
149+
150+
recipient_parsed = urlparse(recipient_inbox)
151+
recipient_host = recipient_parsed.netloc
152+
recipient_path = recipient_parsed.path
153+
154+
follow_request_message = {
155+
"id": activity_id,
156+
"type": "Follow",
157+
"actor": sender_url,
158+
"object": recipient_url,
159+
}
160+
161+
unfollow_request_message = {
162+
"@context": "https://www.w3.org/ns/activitystreams",
163+
"id": f"{activity_id}/undo",
164+
"type": "Undo",
165+
"actor": sender_url,
166+
"object": follow_request_message,
167+
}
168+
169+
digest = base64.b64encode(
170+
hashlib.sha256(json.dumps(unfollow_request_message).encode("utf-8")).digest()
171+
)
172+
173+
current_date = datetime.datetime.now(datetime.timezone.utc).strftime(
174+
"%a, %d %b %Y %H:%M:%S GMT"
175+
)
176+
177+
# signature_text = generate_signing_text
178+
signature_text: bytes = b"(request-target): post " + recipient_path.encode("utf-8")
179+
signature_text += b"\nhost: " + recipient_host.encode("utf-8")
180+
signature_text += b"\ndate: " + current_date.encode("utf-8")
181+
signature_text += b"\ndigest: SHA-256=" + digest
182+
183+
raw_signature = sign(signature_text)
184+
185+
signature_text_b64 = base64.b64encode(raw_signature).decode("utf-8")
186+
signature_header = (
187+
'keyId="'
188+
+ sender_key
189+
+ '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="'
190+
+ signature_text_b64
191+
+ '"'
192+
)
193+
194+
headers = {
195+
"Date": current_date,
196+
"Content-Type": "application/activity+json",
197+
"Host": recipient_host,
198+
"Digest": "SHA-256=" + digest.decode("utf-8"),
199+
"Signature": signature_header,
200+
}
201+
print(headers)
202+
203+
# Now that the header is set up, we will construct the message
204+
r = requests.post(recipient_inbox, headers=headers, json=unfollow_request_message)
205+
print(r)
206+
print(r.content)
207+
208+
209+
if __name__ == "__main__":
210+
command = "follow"
211+
if len(sys.argv) > 1:
212+
if sys.argv[1] == "--unfollow":
213+
command = "unfollow"
214+
215+
if command == "follow":
216+
follow()
217+
elif command == "unfollow":
218+
unfollow()
219+
else:
220+
print("Command not handled")

example/requirements.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
cryptography==38.0.1
2+
requests==2.28.1
3+
flask==3.1.0

example/server.py

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from flask import Flask, request, make_response, abort, Response
2+
import ssl
3+
from config import HOSTNAME, USER
4+
5+
URI = f"https://{HOSTNAME}"
6+
7+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
8+
context.load_cert_chain(
9+
f"/etc/letsencrypt/live/{HOSTNAME}/fullchain.pem",
10+
f"/etc/letsencrypt/live/{HOSTNAME}/privkey.pem",
11+
)
12+
13+
app = Flask(__name__)
14+
15+
16+
@app.route("/users/<username>")
17+
def user(username):
18+
print(f"Received GET request for /users/{username}")
19+
print(request.headers)
20+
21+
if username != USER:
22+
abort(404)
23+
24+
public_key = open("public.pem", "r").read()
25+
26+
response = make_response(
27+
{
28+
"@context": [
29+
"https://www.w3.org/ns/activitystreams",
30+
"https://w3id.org/security/v1",
31+
],
32+
"id": f"{URI}/users/{USER}",
33+
"inbox": f"{URI}/users/{USER}/inbox",
34+
"outbox": f"{URI}/users/{USER}/outbox",
35+
"type": "Person",
36+
"name": USER.title(),
37+
"preferredUsername": USER,
38+
"publicKey": {
39+
"id": f"{URI}/users/{USER}#main-key",
40+
"owner": f"{URI}/users/{USER}",
41+
"publicKeyPem": public_key,
42+
},
43+
}
44+
)
45+
46+
# Servers may discard the result if you do not set the appropriate content type
47+
response.headers["Content-Type"] = "application/activity+json"
48+
49+
return response
50+
51+
52+
@app.route("/users/<username>/inbox", methods=["POST"])
53+
def user_inbox(username):
54+
print(f"Received POST request for /users/{username}/inbox")
55+
print(request.headers)
56+
print(request.data)
57+
58+
if username != USER:
59+
abort(404)
60+
61+
# Accept any message sent to our inbox while testing
62+
return Response("", status=202)
63+
64+
65+
@app.route("/.well-known/webfinger")
66+
def webfinger():
67+
resource = request.args.get("resource")
68+
69+
if resource != f"acct:{USER}@{HOSTNAME}":
70+
abort(404)
71+
72+
response = make_response(
73+
{
74+
"subject": f"acct:{USER}@{HOSTNAME}",
75+
"links": [
76+
{
77+
"rel": "self",
78+
"type": "application/activity+json",
79+
"href": f"{URI}/users/{USER}",
80+
}
81+
],
82+
}
83+
)
84+
85+
# Servers may discard the result if you do not set the appropriate content type
86+
response.headers["Content-Type"] = "application/jrd+json"
87+
88+
return response
89+
90+
91+
@app.route("/", defaults={"path": ""})
92+
@app.route("/<path:path>")
93+
def catch_all(path):
94+
print(path)
95+
print(request.headers)
96+
print(request.data)
97+
98+
return ""
99+
100+
101+
if __name__ == "__main__":
102+
app.run(host="0.0.0.0", port=443, ssl_context=context)

0 commit comments

Comments
 (0)