COSCUP-ap-demo/docs/show.org
2023-07-27 15:15:17 +08:00

14 KiB
Raw Blame History

Show

名正言顺

Nodeinfo

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": {},
        },
    )
GET http://coscup.localhost/.well-known/nodeinfo
// 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
GET http://coscup.localhost/nodeinfo/2.0
// 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

Actor

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": [],
}
@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
GET http://coscup.localhost/user/show

WebFinger

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
GET http://coscup.localhost/.well-known/webfinger?resource=acct:show@coscup.localhost

HTTP Signature

other.localhost Follow-> coscup.localhost

# 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==\""
}
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

parsed_signature = parse_signature(headers["signature"])

 {"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=="}
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,
)

signature_string

(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

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)

Activity!

Follow / Accept

other.localhost Follow-> coscup.localhost

{
   "@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"
}
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)

Creat!

{
   "@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"
   },
}
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",
    }
{"@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!"}}