前回の内容に引き続きさらに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で実装する例(前回の続き)。
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での実装例。
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が返ってくる。この要求には認証処理は必要でない。
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
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
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
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:
# エラー処理
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に反映される。