482 lines
14 KiB
Org Mode
482 lines
14 KiB
Org Mode
#+title: Show
|
|
|
|
** 名正言顺
|
|
*** Nodeinfo
|
|
#+begin_src python
|
|
from flask import Flask, Response, jsonify
|
|
from config import BASE_URL #https://alice.me
|
|
|
|
app = Flask(__name__)
|
|
@app.route("/.well-known/nodeinfo")
|
|
def well_known_nodeinfo() -> Response:
|
|
"""Return nodeinfo path."""
|
|
return jsonify(
|
|
{
|
|
"links": [
|
|
{
|
|
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
|
"href": f"{BASE_URL}/nodeinfo/2.0",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
@app.get("/nodeinfo/2.0")
|
|
def nodeinfo() -> Response:
|
|
"""Return nodeinfo."""
|
|
return jsonify(
|
|
{
|
|
"version": "2.0",
|
|
"software": {
|
|
"name": "COSCUP-demo",
|
|
"version": "0.0.1",
|
|
},
|
|
"protocols": ["activitypub"],
|
|
"services": {"inbound": [], "outbound": []},
|
|
"usage": {"users": {"total": 1}},
|
|
"openRegistrations": False,
|
|
"metadata": {},
|
|
},
|
|
)
|
|
#+end_src
|
|
|
|
#+begin_src restclient
|
|
GET http://coscup.localhost/.well-known/nodeinfo
|
|
#+end_src
|
|
|
|
#+RESULTS:
|
|
#+BEGIN_SRC js
|
|
// GET http://coscup.localhost/.well-known/nodeinfo
|
|
// HTTP/1.1 503 Service Unavailable
|
|
// Connection: close
|
|
// Proxy-Connection: close
|
|
// Content-Length: 0
|
|
// Request duration: 2.318918s
|
|
#+END_SRC
|
|
|
|
#+RESULTS:
|
|
|
|
#+begin_src restclient
|
|
GET http://coscup.localhost/nodeinfo/2.0
|
|
#+end_src
|
|
|
|
#+RESULTS:
|
|
#+BEGIN_SRC js
|
|
// GET https://alice.me/nodeinfo/2.0
|
|
{
|
|
"version": "2.0"
|
|
"openRegistrations": false,
|
|
"protocols": ["activitypub"],
|
|
"software": {
|
|
"name": "COSCUP-demo",
|
|
"version": "0.0.1"
|
|
},
|
|
"usage": {
|
|
"users": {
|
|
"total": 1
|
|
}
|
|
},
|
|
}
|
|
|
|
// HTTP/1.1 200 OK
|
|
// Server: nginx/1.24.0
|
|
// Date: Fri, 14 Jul 2023 07:35:20 GMT
|
|
// Content-Type: application/json
|
|
// Content-Length: 297
|
|
// Connection: keep-alive
|
|
// Request duration: 0.002971s
|
|
#+END_SRC
|
|
|
|
*** Actor
|
|
#+begin_src python
|
|
AS_CTX = "https://www.w3.org/ns/activitystreams"
|
|
AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
|
|
|
|
AS_EXTENDED_CTX = [
|
|
"https://www.w3.org/ns/activitystreams",
|
|
"https://w3id.org/security/v1",
|
|
{
|
|
# AS ext
|
|
"Hashtag": "as:Hashtag",
|
|
"sensitive": "as:sensitive",
|
|
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
|
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
|
|
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
|
|
# toot
|
|
"toot": "http://joinmastodon.org/ns#",
|
|
"featured": {"@id": "toot:featured", "@type": "@id"},
|
|
"Emoji": "toot:Emoji",
|
|
"blurhash": "toot:blurhash",
|
|
"votersCount": "toot:votersCount",
|
|
},
|
|
]
|
|
|
|
ME = {
|
|
"@context": AS_EXTENDED_CTX,
|
|
"type": "Person",
|
|
"id": config.ID,
|
|
"following": config.BASE_URL + "/following",
|
|
"followers": config.BASE_URL + "/followers",
|
|
"featured": config.BASE_URL + "/featured",
|
|
"inbox": config.BASE_URL + "/inbox",
|
|
"outbox": config.BASE_URL + "/outbox",
|
|
"preferredUsername": config.USERNAME,
|
|
"name": config.NICKNAME,
|
|
"summary": config.ACTOR_SUMMARY,
|
|
"endpoints": {
|
|
"sharedInbox": config.BASE_URL + "/inbox",
|
|
},
|
|
"url": config.ID + "/", # Important for Mastodon
|
|
"manuallyApprovesFollowers": config.MANUALLY_APPROVES_FOLLOWERS,
|
|
"attachment": [],
|
|
"icon": {
|
|
"mediaType": "image/png",
|
|
"type": "Image",
|
|
"url": config.AVATAR_URL,
|
|
},
|
|
"publicKey": {
|
|
"id": f"{config.ID}#main-key",
|
|
"owner": config.ID,
|
|
"publicKeyPem": get_pubkey_as_pem(config.KEY_PATH),
|
|
},
|
|
"tag": [],
|
|
}
|
|
#+end_src
|
|
|
|
#+begin_src python
|
|
@app.route(f"/user/{config.USERNAME}") # /user/show
|
|
def locate_user() -> Response:
|
|
"""Return user ActivityPub response."""
|
|
resp = jsonify(ME)
|
|
resp.headers["Content-Type"] = "application/activity+json"
|
|
|
|
return resp
|
|
#+end_src
|
|
|
|
#+begin_src restclient
|
|
GET http://coscup.localhost/user/show
|
|
#+end_src
|
|
|
|
** WebFinger
|
|
#+begin_src python
|
|
from flask import Flask, abort, request, Response, jsonify
|
|
from config import USERNAME, DOMAIN, ID
|
|
#USERNAME = "show"
|
|
#DOMAIN = "coscup.localhost"
|
|
#ID = f"http://{DOMAIN}/user/{USERNAME}"
|
|
|
|
@app.route("/.well-known/webfinger")
|
|
def wellknown_webfinger() -> Response:
|
|
"""Exposes servers WebFinger data."""
|
|
resource = request.args.get("resource")
|
|
|
|
if resource not in [f"acct:{USERNAME}@{DOMAIN}", config.ID]:
|
|
abort(404)
|
|
|
|
resp = jsonify(
|
|
{
|
|
"subject": f"acct:{config.USERNAME}@{config.DOMAIN}",
|
|
"aliases": [config.ID],
|
|
"links": [
|
|
{
|
|
"rel": "http://webfinger.net/rel/profile-page",
|
|
"type": "text/html",
|
|
"href": config.ID,
|
|
},
|
|
{
|
|
"rel": "self",
|
|
"type": "application/activity+json",
|
|
"href": config.ID,
|
|
},
|
|
],
|
|
},
|
|
)
|
|
resp.headers["Access-Control-Allow-Origin"] = "*"
|
|
resp.headers["Content-Type"] = "application/jrd+json; charset=utf-8"
|
|
|
|
return resp
|
|
#+end_src
|
|
|
|
#+begin_src restclient
|
|
GET http://coscup.localhost/.well-known/webfinger?resource=acct:show@coscup.localhost
|
|
#+end_src
|
|
|
|
** HTTP Signature
|
|
other.localhost --Follow-> coscup.localhost
|
|
|
|
#+begin_src json
|
|
# request.data
|
|
{
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
"id": "http://other.localhost/9ef8847d72434cff82254f36ff88e710",
|
|
"type": "Follow",
|
|
"actor": "http://other.localhost/accounts/other",
|
|
"object": "http://coscup.localhost/meow/show"
|
|
}
|
|
|
|
# request.header
|
|
{
|
|
"host":"other.localhost",
|
|
"accept":"*/*",
|
|
"accept-encoding":"gzip, deflate, br",
|
|
"connection":"keep-alive",
|
|
"user-agent":"something/fediverse+app",
|
|
"content-type":"application/activity+json",
|
|
"content-length":"206",
|
|
"date":"Tue, 11 Jul 2023 04:21:11 GMT",
|
|
"digest":"SHA-256=WzNniTyRBcZpgK7a6zYJBgsdKIQmFEPPfysh/ZzKb2o=",
|
|
"signature":"keyId=\"http://other.localhost#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) user-agent host date digest content-type\",signature=\"irioDQuhYstSvafpl6DW4d31wDPRiTv7MZGyBo3j4kkc2TrfOweH3WRMMnoaWwl4LAI2WYLoKefeQpOg7Rm7ZEffsoLOzZvgdWJBm8lnOEgieyy5l2Vq1mlcS2PRJCisYGdzAwFOBkcHk0WKAZXvs1ieRV34NHfM8JF+DjrCBTZ/U9LyxULBwC6tPQTh9tflCOwXZOzXUq17C+2Uzsr8h4tDHjbmrG7OAcvYiPeOUKaP+InoE6j9ViHllhidNCPL0y8b1c7c72ruN48kF42OfyfUeiuCcuLwdp8eYBlTdG/ZsT2YXyKruwim3tTD1TtyW4Vfll+F/4/1RfWHsc9LrQ==\""
|
|
}
|
|
#+end_src
|
|
|
|
#+begin_src python
|
|
def parse_signature(
|
|
signature: str
|
|
) -> dict:
|
|
"""Parse signature string in headers."""
|
|
detail = {}
|
|
for item in signature.split(','):
|
|
name, value = item.split('=', 1)
|
|
value = value.strip('"')
|
|
detail[name.lower()] = value
|
|
signature_details = {
|
|
"headers": detail["headers"].split(),
|
|
"signature": detail["signature"],
|
|
"algorithm": detail["algorithm"],
|
|
"keyid": detail["keyid"],
|
|
}
|
|
return signature_details
|
|
#+end_src
|
|
|
|
parsed_signature = parse_signature(headers["signature"])
|
|
#+begin_src json
|
|
{"algorithm": "rsa-sha256",
|
|
"headers": ["(request-target)", "user-agent", "host", "date", "digest", "content-type"],
|
|
"keyid": "http://other.localhost#main-key",
|
|
"signature": "irioDQuhYstSvafpl6DW4d31wDPRiTv7MZGyBo3j4kkc2TrfOweH3WRMMnoaWwl4LAI2WYLoKefeQpOg7Rm7ZEffsoLOzZvgdWJBm8lnOEgieyy5l2Vq1mlcS2PRJCisYGdzAwFOBkcHk0WKAZXvs1ieRV34NHfM8JF+DjrCBTZ/U9LyxULBwC6tPQTh9tflCOwXZOzXUq17C+2Uzsr8h4tDHjbmrG7OAcvYiPeOUKaP+InoE6j9ViHllhidNCPL0y8b1c7c72ruN48kF42OfyfUeiuCcuLwdp8eYBlTdG/ZsT2YXyKruwim3tTD1TtyW4Vfll+F/4/1RfWHsc9LrQ=="}
|
|
#+end_src
|
|
|
|
#+begin_src python
|
|
import base64
|
|
from Crypto.Hash import SHA256
|
|
|
|
def calculate_digest(
|
|
payload: bytes,
|
|
) -> str:
|
|
"""Calculate digest for given HTTP payload."""
|
|
payload_digest = SHA256.new()
|
|
payload_digest.update(body)
|
|
return "SHA-256=" + \
|
|
base64.b64encode(body_digest.digest()).decode("utf-8")
|
|
|
|
|
|
def build_signature_string(
|
|
method: str,
|
|
path: str,
|
|
sign_headers: list,
|
|
payload_digest: str | None,
|
|
headers,
|
|
) -> str :
|
|
sign_str = []
|
|
for sign_header in sign_headers:
|
|
if sign_header == "(request-target)":
|
|
sign_str.append("(request-target): " + method.lower() + ' ' + path)
|
|
elif sign_header == "digest" and payload_digest:
|
|
sign_str.append("digest: " + payload_digest)
|
|
else:
|
|
sign_str.append(sign_header + ": " + headers[sign_header])
|
|
return "\n".join(sign_str)
|
|
|
|
|
|
signature_string = build_signature_string(
|
|
request.method # "post"
|
|
request.path # "/inbox"
|
|
parsed_signature["headers"], # ["(request-target)", "user-agent", "host", "date", "digest", "content-type"]
|
|
calculate_digest(request.data),
|
|
headers,
|
|
)
|
|
|
|
#+end_src
|
|
|
|
signature_string
|
|
#+begin_quote
|
|
(request-target): post /inbox
|
|
user-agent: something/fediverse+app
|
|
host: other.localhost
|
|
date: Tue, 11 Jul 2023 04:21:11 GMT
|
|
digest: SHA-256=WzNniTyRBcZpgK7a6zYJBgsdKIQmFEPPfysh/ZzKb2o=
|
|
content-type: application/activity+json
|
|
#+end_quote
|
|
|
|
#+begin_src python
|
|
from Crypto.Hash import SHA256
|
|
from Crypto.Signature import PKCS1_v1_5
|
|
from Crypto.PublicKey import RSA
|
|
|
|
def verify_signature(
|
|
signature_string: str,
|
|
signature: bytes, # base64.b64decode(parsed_signature["signature"])
|
|
pubkey: str,
|
|
) -> bool :
|
|
pubkey = RSA.importKey(pubkey)
|
|
signer = PKCS1_v1_5.new(pubkey)
|
|
digest = SHA256.new()
|
|
digest.update(signature_string.encode("utf-8"))
|
|
return signer.verify(digest, signature)
|
|
#+end_src
|
|
|
|
** Activity!
|
|
*** Follow / Accept
|
|
other.localhost --Follow-> coscup.localhost
|
|
#+begin_src json
|
|
{
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
"id": "http://other.localhost/9ef8847d72434cff82254f36ff88e710",
|
|
"type": "Follow",
|
|
"actor": "http://other.localhost/accounts/other",
|
|
"object": "http://coscup.localhost/meow/show"
|
|
}
|
|
#+end_src
|
|
|
|
#+begin_src python
|
|
def _process_inbox_request(...):
|
|
...
|
|
actor = feact_actor(ap_object["actor"])
|
|
...
|
|
if "Follow" == ap_object["type"]:
|
|
if _handle_follow(actor, ap_object):
|
|
...
|
|
elif "Create" == ap_object["type"]:
|
|
if _handle_create(ap_object):
|
|
...
|
|
|
|
|
|
def _handle_follow(...):
|
|
if ME["id"] != ap_object["object"]:
|
|
logger.warning("wrong follow object!" + .ap_object["id"])
|
|
return False
|
|
|
|
if MANUALLY_APPROVES_FOLLOWERS:
|
|
save_follow_request(ap_object)
|
|
return True
|
|
|
|
_send_accept(actor, ap_object)
|
|
save_follower()
|
|
return True
|
|
|
|
|
|
async def _send_accept(...) -> None :
|
|
reply_id = BASE_URL + str(uuid.uuid4())
|
|
|
|
actor_url = actor["inbox"]
|
|
payload = {
|
|
"@context": AS_CTX,
|
|
"id": reply_id,
|
|
"type": "Accept",
|
|
"actor": ID, # http://coscup.localhost/user/show
|
|
"object": ap_object["id"], # http://other.localhost/9ef8847d72434cff82254f36ff88e710
|
|
}
|
|
|
|
post(actor_url, payload)
|
|
#+end_src
|
|
|
|
*** Creat!
|
|
#+begin_src json
|
|
{
|
|
"@context" : [
|
|
"https://www.w3.org/ns/activitystreams",
|
|
{
|
|
"Emoji" : "toot:Emoji",
|
|
"atomUri" : "ostatus:atomUri",
|
|
"conversation" : "ostatus:conversation",
|
|
"focalPoint" : {
|
|
"@container" : "@list",
|
|
"@id" : "toot:focalPoint"
|
|
},
|
|
"inReplyToAtomUri" : "ostatus:inReplyToAtomUri",
|
|
"ostatus" : "http://ostatus.org#",
|
|
"sensitive" : "as:sensitive",
|
|
"toot" : "http://joinmastodon.org/ns#",
|
|
"votersCount" : "toot:votersCount"
|
|
}
|
|
],
|
|
"type":"Create",
|
|
"published" : "2023-07-13T16:04:57Z",
|
|
"cc" : ["http://coscup.localhost/users/show/followers"],
|
|
"to" : ["https://www.w3.org/ns/activitystreams#Public"],
|
|
"id" : "http://coscup.localhost/79d42b46-aa2f-4837-835a-5f75ce9a253f",
|
|
"object": {
|
|
"attributedTo" : "http://coscup.localhost/user/show",
|
|
"cc" : ["http://coscup.localhost/users/show/followers"],
|
|
"to" : ["https://www.w3.org/ns/activitystreams#Public"],
|
|
"id" : "http://coscup.localhost/79d42b46-aa2f-4837-835a-5f75ce9a253f",
|
|
"content" : "<p>Hello World!</p>",
|
|
"inReplyTo" : null,
|
|
"published" : "2023-07-13T16:04:57Z",
|
|
"sensitive" : false,
|
|
"summary" : null,
|
|
"tag" : [],
|
|
"type" : "Note",
|
|
"url" : "http://coscup.localhost/79d42b46-aa2f-4837-835a-5f75ce9a253f"
|
|
},
|
|
}
|
|
#+end_src
|
|
|
|
#+begin_src python
|
|
def _send_create(...):
|
|
object_id =
|
|
to = []
|
|
cc = []
|
|
if visibility == "public":
|
|
to = [ap.AS_PUBLIC]
|
|
cc = [f"{BASE_URL}/followers"]
|
|
else:
|
|
raise ValueError(f"Unsupport visibility {visibility}")
|
|
|
|
ap_object = {
|
|
"type": "Note",
|
|
"id": BASE_URL + str(uuid.uuid4()),
|
|
"attributedTo": ID,
|
|
"content": content,
|
|
"to": to,
|
|
"content": content, # <p>Hello World!<p>
|
|
}
|
|
|
|
ap_object = creat_note(actor, content)
|
|
|
|
inboxes = set()
|
|
for follower in get_followers():
|
|
inboxes.add(follower.sharedInbox or follower.inbox_url)
|
|
|
|
for inbox_url in inboxes:
|
|
client.post(inbox_url, ap_object)
|
|
|
|
return True
|
|
|
|
|
|
def wrap_ap_object(...):
|
|
return {
|
|
"@context": AS_EXTENDED_CTX,
|
|
"actor": config.ID,
|
|
"to": ap_object.get("to", []),
|
|
"cc": ap_object.get("cc", []),
|
|
"id": ap_object["id"],
|
|
"object": ap_object,
|
|
"published": now()
|
|
"type": "Create",
|
|
}
|
|
#+end_src
|
|
|
|
#+begin_src json
|
|
{"@context": "https://www.w3.org/ns/activitystreams",
|
|
"type": "Create",
|
|
"id": "https://alice.me/a29a6843-9feb-4c74-a7f7-081b9c9201d3",
|
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
|
"actor": "https://alice.me/meow/alice",
|
|
"object": {"type": "Note",
|
|
"id": "https://alice.me/49e2d03d-b53a-4c4c-a95c-94a6abf45a19",
|
|
"attributedTo": "https://alice.me/meow/alice",
|
|
"to": ["https://www.w3.org/ns/activitystreams#Public"],
|
|
"content": "Hello world!"}}
|
|
|
|
#+end_src
|