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

[前回の内容](https://qiita.com/wakin/items/94a0ff3f32f842b18a25)に引き続きさらにMastodonとやり取りが出来るようなActivityPubを書いてみる。

# Noteを返すActivityPubの実装
[Personを返すActivityPub](https://qiita.com/wakin/items/94a0ff3f32f842b18a25#person%E3%82%92%E8%BF%94%E3%81%99activitypub%E3%81%AE%E5%AE%9F%E8%A3%85)と同様の流れで実装するだけ。

```python
{
    '@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で実装する例（前回の続き）。

```python: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での実装例。

```python: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に返す。

```python
{
    '@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が返ってくる。この要求には認証処理は必要でない。

```python: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
```

```python: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に付加する。

```python
{
    '@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
```

```python: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
```

```python: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:
        # エラー処理
```

```python: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する。

```python
{
    `@context`: 'https://www.w3.org/ns/activitystreams',
    'type': 'Create',
    `object`: {
        # 投稿するNoteアクティビティのJSON
    }
}
```
これをフォロワーのINBOXへ、AcceptのPOSTと同様に署名付きヘッダーを付けて送ればMastodonのフォロワーのHTLやインスタンスのFTLに反映される。

[成果物をGithubに公開しています。](https://github.com/wakin-/simple_ap)