Help us understand the problem. What is going on with this article?

Mastodonからフォロー出来て投稿が反映されるActivityPubを実装してみる

More than 1 year has passed since last update.

前回の内容に引き続きさらにMastodonとやり取りが出来るようなActivityPubを書いてみる。

Noteを返すActivityPubの実装

Personを返すActivityPubと同様の流れで実装するだけ。

{
    '@context': 'https://www.w3.org/ns/activitystreams',
    'type': 'Note',
    'id': 'https://example.com/test/1', # Fediverseで一意
    'attributedTo': 'https://example.com/test', # 投稿者のPerson#id
    'content': '<p>投稿内容</p>' # XHTMLで記述された投稿内容
    'published': '2018-06-18T12:00:00+09:00' # ISO形式の投稿日
    'to': [ # 公開範囲
        'https://www.w3.org/ns/activitystreams#Public', # 公開(連合?)
        'https://example.com/test/follower', # フォロワー
    ]
}

flaskで実装する例(前回の続き)。

api.py
import json
from flask import Flask, Response

app = Flask(__name__)

...

@app.route('/note')
def note():
    response = {
        ...
    }
    return Response(json.dumps(response), headers={'Content-Type': 'application/activity+json'})

if __name__ == "__main__":
    app.run()

Mastodonの検索エリアに https://example.com/note と入れるとトゥートとして返ってくると思う。

フォローを受け付ける処理の実装

前回の実装のままでも、Mastodonからフォローボタンを押せばフォロワーのカウント数が増えてフォローしているように見える。しかしフォローワー一覧等には反映されない。これはフォロー処理が完了していないためである。フォロー処理を完了させるためにはFollowのアクティビティに対してAcceptアクティビティを返す必要がある。

INBOXの実装

まずはアクティビティを受け付けるINBOXを実装する。Personのinboxパラメータに指定したパスへのPOSTを処理できるようにする。

flaskでの実装例。

api.py
import json
from flask import Flask, Response, request

app = Flask(__name__)

...

@app.route('/inbox', methods=['POST'])
def inbox():
    if request.headers['Content-Type'] != 'application/activity+json':
        return Response(status=400)

    jsn = request.json
    if type(jsn)!=dict or 'type' not in jsn:
        return Response(status=400)
    elif jsn['type'] == 'Follow':

        # Follow処理を書く

        # Acceptを返す処理を書く

        return Response(status=200)
    elif jsn['type'] == 'Undo':
        obj = jsn['object']
        if type(obj)!=dict or 'type' not in obj:
            return Response(status=400)
        elif obj['type'] == 'Follow':

            # Unfollow処理を書く

            # Acceptを返す処理を書く

            return Response(status=200)

    return Response(status=501)

if __name__ == "__main__":
    app.run()

Acceptアクティビティ

Followの処理後、アクティビティを受け付けたことを示す以下のようなAcceptを、Followを投げてきたアカウントのINBOXに返す。

{
    '@context': 'https://www.w3.org/ns/activitystreams',
    'type': 'Accept',
    'actor': 'https://example.com/test', # フォローを受け付けるアカウントのPersonのid
    'object': {
        # FollowアクティビティのJSON
    }
}

INBOXへPOSTする

MastodonのINBOXへアクティビティを届けるためには、公開鍵暗号方式による認証処理が必要になる。

INBOXの解決

INBOXの解決は、アクティビティのactorへの問い合わせで行う。Mastodonでは次のようにしてJSONを要求するとPersonが返ってくる。この要求には認証処理は必要でない。

lib.py
import requests

def actor_inbox(actor):
    response = requests.get(actor+".json")
    if response.status_code >= 400 and response.status_code < 600:
        # エラー処理
    return response.json()['inbox']

公開鍵、秘密鍵の生成

RSA暗号で公開鍵と秘密鍵を生成する。

$ pip install pycrypto
lib.py
from Crypto.PublicKey import RSA
from Crypto import Random

...

def generate_key_pair():
    rsa = RSA.generate(2048, Random.new().read)
    return [rsa.exportKey().decode('utf-8'), rsa.publickey().exportKey().decode('utf-8')]

公開鍵をPersonのJSONに付加する。

{
    '@context': 'https://www.w3.org/ns/activitystreams',
    'type': 'Person',
    ...
    'publicKey': { # Keyアクティビティ
        '@context': 'https://www.w3.org/ns/activitystreams',
        'type': 'Key',
        'id': 'https://example.com/test/pubkey', # keyのid(?)
        'owner': 'https://example.com/test', # Personのid
        'publicKeyPem': '-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----' # PEM形式の公開鍵
    }
}

アカウント解決時にこのPersonを返す事でMastodon側に公開鍵が保持される。

キーペアを用いた署名の生成

MastodonのINBOXへのPOST時に行われる認証処理は、このキーペアで生成した署名で行われる。

pip install httpsig
lib.py
from httpsig import HeaderSigner
from datetime import datetime

...

def sign_headers():
    sign = HeaderSigner(
        'https://example.com/test', # keyId Personのid
        '-----BEGIN RSA PRIVATE KEY----- ... -----END RSA PRIVATE KEY-----', # 秘密鍵
        algorithm='rsa-sha256',
        headers=['(request-target)', 'date']
    ).sign({'Date': datetime.now().isoformat()}, method=method, path=path)
    auth = sign.pop('authorization')
    sign['Signature'] = auth[len('Signature '):] if auth.startswith('Signature ') else ''
    return sign

AcceptをINBOXへPOSTする実装(署名付きヘッダー)

pip install requests
lib.py
import requests

...

def accept(inbox, activity):
    jsn = {
        ...
        'object': activity
    }
    headers = sign_headers()
    response = requests.post(inbox, json=jsn, headers=headers)
    if response.status_code >= 400 and response.status_code < 600:
        # エラー処理
api.py
import json
from flask import Flask, Response
from httpsig import HeaderSigner
from datetime import datetime
from .lib import actor_inbox, accept

app = Flask(__name__)

@app.route('/inbox', methods=['POST'])
def inbox():
    if request.headers['Content-Type'] != 'application/activity+json':
        return Response(status=400)

    jsn = request.json
    if type(jsn)!=dict or 'type' not in jsn:
        return Response(status=400)
    elif jsn['type'] == 'Follow' and 'actor' in jsn:

        # Follow処理を書く

        inbox = actor_inbox(jsn['actor'])
        accept(inbox, jsn)

        return Response(status=200)
    elif jsn['type'] == 'Undo':
        obj = jsn['object']
        if type(obj)!=dict or 'type' not in obj:
            return Response(status=400)
        elif obj['type'] == 'Follow' and 'actor' in obj:

            # Unfollow処理を書く

            inbox = actor_inbox(obj['actor'])
            accept(inbox, jsn)

            return Response(status=200)

    return Response(status=501)

if __name__ == "__main__":
    app.run()

Acceptを返すことでフォロー処理が完了し、Mastodon側でもフォロー関係が反映される。

投稿をMastodonに反映させる

新しい記事の投稿をMastodonに反映させるには、フォロワーのINBOXに対して以下のCreateアクティビティをPOSTする。

{
    `@context`: 'https://www.w3.org/ns/activitystreams',
    'type': 'Create',
    `object`: {
        # 投稿するNoteアクティビティのJSON
    }
}

これをフォロワーのINBOXへ、AcceptのPOSTと同様に署名付きヘッダーを付けて送ればMastodonのフォロワーのHTLやインスタンスのFTLに反映される。

成果物をGithubに公開しています。

wakin
m-gild
最先端技術のMEISTERを目指し、お互い切磋琢磨するGUILD。Webシステム/サービス開発、スマホアプリ開発、AR/VR/MR開発など、様々なニーズに応えます。
https://www.m-gild.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした