[Mastodon]10種類のWebフレームワークでActivityPub実装 一(Express, Fastify)

21 min read読了の目安(約19000字

メリークリスマス

この記事はFediverse (3) Advent Calendar 2020の23日目です。昨日はしゅまりさんの「この日でgensokyo.townが3年目なので徒然なるままに何か」、明日はID:weepさんの「#InstanceTicker のあれやこれ!」でした。

ここから追記
ID:weepさんへの私信
PSHはStrawberryFields Flaskとして完成したものをマージして来年再稼働する予定です。現在のPSHはDigestヘッダを使用せずPOSTする実装のため一部サーバーに投稿が届いていないようです。Zeit NowがVercelに変わったためメンテナンスが滞ってしまい申し訳ないです。
追記ここまで

今回10種類のWebフレームワークでActivityPub実装を行うプロジェクト「StrawberryFields」を立ち上げました。

TL;DR

  • Mastodon上でフォロー、アンフォローできるようにする。
  • curlで直接URLへPOSTしMastodonアカウントをフォロー、アンフォローできるようにする。

今回は上記2点を目標とし、実装していきたいと思います。そのために必要な技術は以下のとおりです。

  • Web frameworkを使用してリクエストを受け取り、レスポンスを返す。
  • HTTP Clientを使用してリクエストを受け取り、レスポンスを返す。
  • リクエストヘッダ、リクエストボディを受け取る。
  • レスポンスヘッダ、レスポンスボディを返す。
  • ActivityPubを実装する。
  • MastodonがサーバーをGETしてくれるようにするため、Webfingerを実装する。
  • MastodonがサーバーをGETしてくれるようにするため、Activity Streamsを実装する。
  • 必要なJSON-LDとしてActivity VocabularyとSecurity Vocabularyを理解する。
  • 署名をする必要があるので、Signing HTTP Messages(旧HTTP Signatures)を実装する。
  • なるほどRSA-SHA256の署名と検証を学ぶ必要があったんですね。
  • SHA256?
  • RSA難しい。
  • ActivityPubって何だ?
  • ActivityPub何もわからん。
  • ?????????
  • ActivityPub完全に理解した。

最初からMVCに基づいたSNSを作っても破綻するし、ActivityPubチョットデキルことを目標としたほうが良さそうなので、10種類のWebフレームワークでActivityPub実装することにしました。

Take 1:
StrawberryFields Express
StrawberryFields Fastify

Take 2:
StrawberryFields Flask
StrawberryFields FastAPI

Take 3:
StrawberryFields Gin
StrawberryFields Sinatra

Take 4:
StrawberryFields Django
StrawberryFields Rails

Take 5:
StrawberryFields Lumen
StrawberryFields Laravel

EX:
StrawberryFields Spark
StrawberryFields Spring

当初Javaでも実装を行う予定で12種類だったのですが、コンテナの都合上未定となりました。

はじめに

現在Take 1に該当するExpressとFastifyで実装を終えたので、Expressを例に駆け足で必要な技術について解説していきたいと思います。
チョットデキルようになっただけなので以下の内容については解説しません。

  • リファレンスを読めば分かる内容。

  • cryptoがどのような計算を行っているか。

  • PythonにおけるUvicornやGunicorn、RubyにおけるPumaやUnicorn、JavaにおけるJettyやTomcatのようなアプリケーションサーバーの解説。

  • nginxやApache、CaddyのようなWebサーバー、プロキシサーバー、ロードバランサーの解説。

  • 環境構築の方法とGit、コンテナ、PaaSの利用方法。

  • 型や文字コードの説明(後日解説記事を書くかもしれません)

ちなみに今回以下の環境で開発を行いました。

A:

  • Windows 10(cmd, PowerShell)
  • ssh-keygen(OpenSSH)
  • curl
  • tar

B:

  • Glitch(WIP)
  • Heroku(Stable)
  • Gitpod(Canary)

C:

  • host: 0.0.0.0
  • port: 8080

D:

  • JavaScript: ES8(ES6 + async/await)
  • Node.js: 12.20.0
  • Express: 4.17.1
  • Fastify: 3.9.1
  • axios: 0.21.0

詳細はpackage.jsonとStrawberryFields Docsをご確認ください。
またBash、ZshのようなPOSIXシェルコマンドをあわせて記載します。
すでにお気づきかと思いますが、長文を何も考えずに執筆しているのであらかじめご了承ください。

様式

/* 1-1 */ let crypto = require('crypto');
/* 1-1 */ let express = require('express');
/* 1-1 */ let axios = require('axios');
/* 1-1 */ let app = express();
/* 1-1 */ require('dotenv').config();

/* 1-2 */ const CONFIG = { preferredUsername: 'a', name: 'Alice' };
/* 3-3 */ const PRIVATE_KEY = JSON.parse(`"${process.env.PRIVATE_KEY}"`);
/* 3-3 */ const PUBLIC_KEY = crypto.createPublicKey(PRIVATE_KEY).export({ type: 'pkcs1', format: 'pem' });

/* 1-3 */ app.use('/public', express.static('public'));
/* 1-3 */ app.use(express.json({ type: 'application/activity+json' }));

/* 3-1 */ let getInbox = async req => {...};
/* 3-5 */ let postInbox = async (req, res, headers) => {...};
/* 3-4 */ let signHeaders = (res, string_name, string_host, string_inbox) => {...};
/* 3-2 */ let acceptFollow = async (string_name, string_host, x, y) => {...};
/* 3-2 */ let follow = async (string_name, string_host, x) => {...};
/* 3-2 */ let undoFollow = async (string_name, string_host, x) => {...};

/* 1-4 */ app.get('/', (req, res) => res.type('text/plain').send('StrawberryFields Express'));

/* 2-1 */ app.get('/u/:string_name', (req, res) => {...});
/* 2-6 */ app.get('/u/:string_name/inbox', (req, res) => res.sendStatus(405));
/* 2-6 */ app.post('/u/:string_name/inbox', async (req, res) => {...});
/* 2-4 */ app.post('/u/:string_name/outbox', (req, res) => res.sendStatus(405));
/* 2-4 */ app.get('/u/:string_name/outbox', (req, res) => {...});
/* 2-4 */ app.get('/u/:string_name/following', (req, res) => {...});
/* 2-4 */ app.get('/u/:string_name/followers', (req, res) => {...});
/* 2-5 */ app.post('/s/:secret/u/:string_name', async (req, res) => {...});
/* 2-3 */ app.get('/.well-known/webfinger', (req, res) => {...});

/* 2-2 */ app.get('/@', (req, res) => res.redirect('/'));
/* 2-2 */ app.get('/u', (req, res) => res.redirect('/'));
/* 2-2 */ app.get('/user', (req, res) => res.redirect('/'));
/* 2-2 */ app.get('/users', (req, res) => res.redirect('/'));
/* 2-2 */ app.get('/@:string_name', (req, res) => res.redirect(`/u/${req.params.string_name}`));
/* 2-2 */ app.get('/user/:string_name', (req, res) => res.redirect(`/u/${req.params.string_name}`));
/* 2-2 */ app.get('/users/:string_name', (req, res) => res.redirect(`/u/${req.params.string_name}`));

/* 1-5 */ app.listen(process.env.PORT || 8080);

これはソースコード全文のアウトラインを抽出したものです。
左側に記載された1-1から順番に説明していきますが、定数と関数は先に宣言し、後ほど定義する際に説明したいと思います。

連想配列

JavaScriptやJSONにおけるObject、PythonにおけるDict、RubyにおけるHashを指します。

パラメータ

example.com/u/aにおけるaを指します。

クエリ

example.com/.well-known/webfinger?resource=acct:a@example.orgにおける?resource=acct:a@example.orgを指します。

シークレット

process.env.SECRETで定義された管理者権限を持ったURLで使用している環境変数です。「curlで直接URLへPOSTしフォロー、アンフォローできるようにする。」を実現するための、正規表現における[a-zA-Z0-9-_]{48,64}のStringであり、決して他人に知られてはなりません。

設定変数

CONFIG = { preferredUsername: 'a', name: 'Alice' }で宣言した定数ですが、各自好きな連想配列に変更することが出来ます。

1-1

let crypto = require('crypto');
let express = require('express');
let axios = require('axios');
let app = express();
require('dotenv').config();

使用するモジュールを読み込みます。

1-2

const CONFIG = { preferredUsername: 'a', name: 'Alice' };

定数を定義します。

const PRIVATE_KEY = null;
const PUBLIC_KEY = null;

定数を先にヌル値で宣言しておきます。後ほど定義します。

1-3

app.use('/public', express.static('public'));

Mastodonではアイコンとヘッダを表示できるため、/publicディレクトリにある静的ファイルをサーバーに置けるようミドルウェアを読み込みます。

ヒント
Mastodonはsvgファイルをアイコンやヘッダとして表示されないよう作られていますが、他のActivityPub実装では表示できることがあります(Pleromaなど)

app.use(express.json({ type: 'application/activity+json' }));

Body ParserがないとMastodon側からサーバーへリクエストボディをPOST出来ないため、ミドルウェアを読み込みます。

let getInbox = async req => {
  console.log('3-1');
};
let postInbox = async (req, res, headers) => {
  console.log('3-4');
};
let signHeaders = (res, string_name, string_host, string_inbox) => {
  console.log('3-3');
};
let acceptFollow = async (string_name, string_host, x, y) => {
  console.log('3-2');
};
let follow = async (string_name, string_host, x) => {
  console.log('3-2');
};
let undoFollow = async (string_name, string_host, x) => {
  console.log('3-2');
};

関数を先にスタブとして宣言しておきます。後ほど定義します。

1-4

app.get('/', (req, res) => res.type('text/plain').send('StrawberryFields Express'));

ここでトップページを表示します。ExpressのHello, World!でおなじみですね。

app.get('/', (req, res) => res.send('Hello, World!'));

注記
今回Content-Typeはtext/plain、application/jrd+json、application/activity+jsonの3つのみで実装します。text/htmlは簡単そうに見えて説明が難しいものです。

1-5

app.listen(process.env.PORT || 8080);

アプリケーションをを起動します。

注意
process.env.PORTだけ記載した場合、環境変数PORTへ代入を行っていない際任意の未使用ポートが割り当てられます。ポートが異なりレスポンスを受け取ることが出来ない現象に悩まされる可能性があるためポートの固定をおすすめします。

ヒント
Herokuではデプロイ時に環境変数PORTにポート番号が割り当てられます。

まだパッケージのインストールが終わっていない場合はpackage.jsonを参考にしてインストールするか、package.jsonをコピーして、cmd.exe(以降ターミナルと記載)上で

$ npm i

の後に、

$ npm start

を入力すると起動するのではないのでしょうか。または次のコマンドをお試しください。

$ node index.js

注記
環境構築が人それぞれなので説明が難しいです。Gitpodを使用すると本番環境に近い開発環境を試すことができるためおすすめです。
Gitpodではコンテナが稼働しているため0.0.0.0:8080が割り当てられ、nginxがプロキシサーバーまたはロードバランサーとして機能し8080-<uuid>-<region>.gitpod.io:443のhttpsプロトコルURLが発行されます。StrawberryFieldsはこちらで動作を確認しています。
便宜上今後はexample.comで説明し、説明に不要なレスポンスヘッダを省略します。

それではターミナル上でcurlを使って表示してみましょう。

$ curl  "https://example.com/"
StrawberryFields Express

表示されました。続いてレスポンスヘッダもあわせて表示してみましょう。

$ curl -i  "https://example.com/"
HTTP/1.1 200 OK
Server: nginx/1.14.1
Date: Wed, 23 Dec 2020 00:01:00 GMT
Content-Type: text/plain
Content-Length: 24
Connection: keep-alive

StrawberryFields Express

よかったですね。

2-1

無事サーバーを起動できた人は素質があると思うのでこれから頑張りましょう。
難易度が跳ね上がります。

注記
JSONの中身も記載、説明したかったのですが長すぎるし意味不明な文章になる可能性が高かったため省略しています。参考文献をご確認ください。

app.get('/u/:string_name', (req, res) => {
  if (req.params.string_name !== CONFIG.preferredUsername) res.sendStatus(404);
  let string_name = req.params.string_name;
  let string_host = req.hostname;
  if (req.header('Accept').includes('application/activity+json')) {
    return res.type('application/activity+json').json({...});
  }
  res.send(`${string_name}=${CONFIG.name}`);
});

ルーティングをやっていきます。

app.get('/u/:string_name', (req, res) => {...});

この処理を行うことで/u/aや/u/b...といった具合に様々なパラメータのユーザーページを表示することが出来ます。しかしながらFederationにおいて大量のユーザーを抱え込むのは本末転倒です。そこで今回はユーザー一人だけを表示されるようにして、その他のユーザーについては404を返すようにします。

if (req.params.string_name !== CONFIG.preferredUsername) res.sendStatus(404);

今回設定変数でaを定義しているため、この条件処理を行うことで/u/a以外のルーティングが行われた際404を返すようにしています。/u/aのルーティングが行われた場合は次の処理へ進みます。

let string_name = req.params.string_name;
let string_host = req.hostname;

リクエストのプロパティが長いので変数にします。

注記
今回説明した内容と同様の処理を行うことが今後何回かあるので、そちらは説明を省略します。

if (req.header('Accept').includes('application/activity+json')) {
  return res.type('application/activity+json').json({...});
}
res.type('text/plain').send(`${string_name}=${CONFIG.name}`);

これはリクエストヘッダを確認して、Accept(要求)されたContent-Typeがapplication/activity+jsonの場合Activity Streamsを返すよう処理を行っています。
条件に該当しない場合はtext/plainを返すようにしています。

注意
ActivityPubではActivity StreamsというJSON-LDを使用しており、キーに使用されているプロパティはActivity Vocabularyより確認できます。またこの処理では唯一Activity Vocabularyでは定義していないキーpublicKeyを使用しており、こちらはSecurity Vocabularyより確認できます。

このapplication/activity+jsonを要求してきたらレスポンスタイプはapplication/activity+jsonのJSON-LDを返すことがとても重要です。

2-2

app.get('/@', (req, res) => res.redirect('/'));
app.get('/u', (req, res) => res.redirect('/'));
app.get('/user', (req, res) => res.redirect('/'));
app.get('/users', (req, res) => res.redirect('/'));
app.get('/@:string_name', (req, res) => res.redirect(`/u/${req.params.string_name}`));
app.get('/user/:string_name', (req, res) => res.redirect(`/u/${req.params.string_name}`));
app.get('/users/:string_name', (req, res) => res.redirect(`/u/${req.params.string_name}`));

怒涛のルーティングがありますが、これらはFederationの実装で誤ったリンク(暗黙知で定義された仕様に記載のないURL)から訪問してきたユーザーへエラー表示を見せてがっかりさせないようリダイレクトしている処理です。

ヒント
ActivityPubではエンドポイントの仕様を定義していますが、エンドポイントまでたどり着くためのパスについては特に定義されていません。Mastodonが実装した/usersまたは/@からはじまるパスに倣って実装されていることが多いのでStrawberryFieldsのような/uからはじまるパスは少数のようです。

2-3

app.get('/.well-known/webfinger', (req, res) => {
  let string_name = CONFIG.preferredUsername;
  let string_host = req.hostname;
  if (req.query.resource !== `acct:${string_name}@${string_host}`) return res.sendStatus(404);
  res.type('application/jrd+json').json({...});
});

これはWebFingerです。/.well-known/webfingerパスにクエリを使うことでリンクなどを探してくれる便利な仕様をMastodonでは使用しています。
そのためこのパスがないとMastodon側ではアカウントとして認識してくれないため実装する必要があります。acct:にまるでメールアドレスのような文字列を付与したものをresourceクエリで取得します。

  if (req.query.resource !== `acct:${string_name}@${string_host}`) return res.sendStatus(404);

完全一致するacct:があった場合は

  res.type('application/jrd+json').json({...});

application/jrd+jsonでjsonを返します。

注意
Googleなどで検索するとWebFingerについて/.well-known/host-metaやlrddといった謎の情報が出てきます。どうやらこれはapplication/xrd+xmlと呼ばれる古いWebFingerで使用されている仕様らしく、現在はモダンなapplication/jrd+jsonを使用すればよいので無視して構わないそうです。ただXMLを使用している古のFederationと仲良くするためには古い仕様が必要になるかもしれないです……。

2-4

app.post('/u/:string_name/outbox', (req, res) => res.sendStatus(405));
app.get('/u/:string_name/outbox', (req, res) => {
  if (req.params.string_name !== CONFIG.preferredUsername) return res.sendStatus(404);
  if (!req.header('Accept').includes('application/activity+json')) return res.sendStatus(400);
  let string_name = CONFIG.preferredUsername;
  let string_host = req.hostname;
  res.type('application/activity+json').json({...});
});
app.get('/u/:string_name/following', (req, res) => {...});
app.get('/u/:string_name/followers', (req, res) => {...});

ActivityPubではエンドポイントinboxのほかoutboxを必ず使用する必要があり、following、followersは使用を推奨するものとなっています。それぞれ投稿、フォロー、フォロワーを指しており、Collection(またはソート可能なOrderedCollection)モデルに基づきサーバーおよびクライアントから取得することが可能らしいです。しかしながら今回はモデルとビューを使用しないため必要最低限の実装であるアイテム数の数値のみ返すよう実装を行っています。

また基本的にActivityPubエンドポイントはapplication/activity+jsonしか返さないためMethodやAcceptに対し分かりやすいエラーを返すようにしています。

注意
MastodonではPostgreSQLの数値データ型bigintに基づき9223372036854775808までDBに格納されるようです(表示は9223372036854710000) Federationでは第三者の改ざん検知を行うことが可能ですが管理者本人が生成した数値については訂正を行わないようです。ただ理不尽な理由でドメインブロックされることがあったりするのであまりおすすめしません。

ヒント
ActivityPubではinboxへPOSTするS2S実装の他outboxへPOST、inboxをGETするC2S実装も紹介していますが実装しているサーバーは少ないようです。そもそもClientとは何ぞやといった話になるためOAuth2.0認証が難しいといった話を聞きます。またoutboxにあるcontentそのものをGETするS2S実装はMastodonでは行っていませんが(TLにWebSocketを使用しているためロングポーリングする必要がない)一部ActivityPub実装やFederationサーバー(フォークしたもの)では実装しているそうです。

2-5

TODO

app.post('/s/:secret/u/:string_name', async (req, res) => {
  if (req.params.string_name !== CONFIG.preferredUsername) return res.sendStatus(404);
  if (!req.params.secret || req.params.secret === '-') return res.sendStatus(404);
  if (req.params.secret !== process.env.SECRET) return res.sendStatus(404);
  if (!req.query.id || !req.query.type) return res.sendStatus(400);
  let string_name = req.params.string_name;
  let string_host = req.hostname;
  if (new URL(req.query.id).protocol !== 'https:') return res.sendStatus(400);
  let x = await getInbox(req.query.id);
  if (!x) return res.sendStatus(500);
  if (req.query.type === 'type') {
    console.log(x.type);
    return res.status(200).end();
  }
  if (req.query.type === 'follow') {
    follow(string_name, string_host, x);
    return res.status(200).end();
  }
  if (req.query.type === 'undo_follow') {
    undoFollow(string_name, string_host, x);
    return res.status(200).end();
  }
  res.sendStatus(501);
});

2-6

app.get('/u/:string_name/inbox', (req, res) => res.sendStatus(405));
app.post('/u/:string_name/inbox', async (req, res) => {
  if (req.params.string_name !== CONFIG.preferredUsername) return res.sendStatus(404);
  if (!req.header('Content-Type').includes('application/activity+json')) return res.sendStatus(400);
  let string_name = req.params.string_name;
  let string_host = req.hostname;
  let y = req.body;
  if (new URL(y.actor).protocol !== 'https:') return res.sendStatus(400);
  let x = await getInbox(y.actor);
  if (!x) return res.sendStatus(500);
  if (y.type === 'Follow') {
    acceptFollow(string_name, string_host, x, y);
    return res.status(200).end();
  }
  if (y.type === 'Undo') {
    let z = y.object;
    if (z.type === 'Follow') {
      acceptFollow(string_name, string_host, x, z);
      return res.status(200).end();
    }
  }
  res.sendStatus(501);
});

3-1

let getInbox = async req => {
  let res = await axios.get(req, { headers: { Accept: 'application/activity+json' } });
  return res.data;
};

3-2

let acceptFollow = async (string_name, string_host, x, y) => {
  let int_id = Math.floor(Date.now() / 1000);
  let string_inbox = x.inbox;
  let res = {...};
  let headers = signHeaders(res, string_name, string_host, string_inbox);
  await postInbox(string_inbox, res, headers);
};
let follow = async (string_name, string_host, x) => {...};
let undoFollow = async (string_name, string_host, x) => {...};

3-3

const PRIVATE_KEY = JSON.parse(`"${process.env.PRIVATE_KEY}"`);
const PUBLIC_KEY = crypto.createPublicKey(PRIVATE_KEY).export({ type: 'pkcs1', format: 'pem' });

3-4

let signHeaders = (res, string_name, string_host, string_inbox) => {
  let string_time = new Date().toUTCString()
  let s256 = crypto.createHash('sha256').update(JSON.stringify(res)).digest('base64');
  let sig = crypto.createSign('sha256').update(
      `(request-target): post ${new URL(string_inbox).pathname}\n` +
      `host: ${new URL(string_inbox).host}\n` +
      `date: ${string_time}\n` +
      `digest: SHA-256=${s256}`
    ).end();
  let b64 = sig.sign(PRIVATE_KEY, 'base64');
  let headers = {
    Host: new URL(string_inbox).host,
    Date: string_time,
    Digest: `SHA-256=${s256}`,
    Signature:
      `keyId="https://${string_host}/u/${string_name}",` +
      `algorithm="rsa-sha256",` +
      `headers="(request-target) host date digest",` +
      `signature="${b64}"`,
    Accept: 'application/activity+json',
    'Content-Type': 'application/activity+json',
    'Accept-Encoding': 'gzip',
    'User-Agent': `axios/0.21.0 (StrawberryFields Express/1.0.0; +https://${string_host}/)`
  };
  return headers;
};

3-5

let postInbox = async (req, res, headers) => {
  await axios.post(req, JSON.stringify(res), { headers: headers });
};

参考文献