iori: MastodonやMisskeyと連携できるブログをリリースした
kosui @kosui@blog.kosui.me
ioriについて
ActivityPubを部分的にサポートするioriは、自分のためのナレッジ管理サービスとして開発した。
現代では様々なブログサービスやナレッジ共有サービスが存在するが、どれもいつか滅びてしまうリスクを抱えている。
それは仕方がないことだが、自分が得た知識や情報がある日突然アクセスできなくなるのは避けたい。
そこで、ioriでは自分で情報のフローをコントロールできることを重視し、ActivityPubを通じて自由な形式でナレッジが共有できるように設計した。
特定のサービスのレコメンド機能に依存せず、読者が自分の好きなサービスから情報を取得できることを目指している。
ioriとHono
ioriはHonoとFedifyを利用して構築されており、TypeScriptで書かれている。
元々は hono/jsx を使用してテンプレートエンジンのようにJSXを使うSSRアプリケーションとして実装していたが、インタラクティブなタイムラインのUIを実現するために、一部のUIはReactに置き換えた。
Honoの魅力の一つとして、このように「小さく始めて、大胆に拡張する」ことが容易である点が挙げられる。最初はシンプルなSSRアプリケーションとして始め、フロントエンドとバックエンドがどのような接合点を持つべきか設計の道筋が見えた段階でReactへ移行できるのは非常に便利だ。
ioriとFedify
ioriがActivityPubをサポートできているのは、間違いなくFedifyのおかげである。
自分でActivityPubの仕様を一から実装しようとしたことはこれまでに何度もあるが、以下の問題にぶつかり、いつも挫折してきた。
- データモデリングの対象が広範囲に及ぶ
- Vocabularyが多岐にわたる
- オブジェクトタイプやアクタータイプが多い
- ネットワーク通信の仕様が複雑
- HTTPシグネチャチャの実装
- JSON-LDのコンテキスト解決
私が本当に提供したいのはナレッジ管理サービスであり、ActivityPubの実装ではない。Fedifyはこれらの複雑さを抽象化し、開発者がビジネスロジックに集中できるようにしてくれる。
ioriの機能
ioriは個人向けのマイクロブログとして、必要十分な機能を実装している。
Fediverse連携
MastodonやMisskey、Fedibird、Pleroma などのFediverseプラットフォームから、ioriのアカウントをフォローできる。逆に、ioriからリモートのアカウントをフォローすることも可能だ。いいねやリポストもActivityPubを通じて双方向にやり取りされる。
一部のActivityPub実装で対応している絵文字リアクションもサポートしている。一方で、投票機能などはioriが解決したい関心事には不要と判断し、実装していない。
Markdown
投稿はMarkdownで書ける。コードブロックはShikiを使ったシンタックスハイライトに対応しており、ダークモード時は自動でテーマが切り替わる。技術ブログとして使うなら欠かせない機能だ。
記事機能
複数の投稿をスレッドとしてまとめ、1つの記事として公開できる。この記事自体もその機能を使って書いている。ActivityPubの Article オブジェクトとして配信されるため、Fediverseからも閲覧可能だ。
Web Push通知
フォローやいいね、絵文字リアクションがあったときに、ブラウザのプッシュ通知を受け取れる。VAPID認証を使った標準的なWeb Push APIで実装している。
OGP画像の自動生成
記事にはsatoriを使ってOGP画像を動的に生成している。日本語フォント(Noto Sans JP)に対応しており、SNSでシェアしたときにタイトルがきれいに表示される。
開発中に直面した課題
ioriの開発で最も苦戦したのは、HTTP署名と認証まわりだった。
ActivityPubと認証の関係
ActivityPub仕様は認証について明示的に規定していない。これは設計上の欠陥ではなく、意図的な選択だ。W3Cの仕様書では、認証メカニズムについて「No particular mechanism for verification is authoritatively specified」と述べられている。
分散システムでは、各サーバーが独自のポリシーを持つ。認証方式を仕様で強制すると、新しい実装の参入障壁が高くなり、エコシステムの多様性が失われる。代わりに、コミュニティが実際の運用に基づいて慣習的なプロファイルを発展させてきた。
現在、ほぼ全ての実装系がHTTP Signaturesをサポートしている。これはSWICG(Social Web Incubator Community Group)が文書化を進めているが、現時点では公式仕様ではない。
Authorized Fetch(Secure Mode)
Mastodonの一部サーバーは「Authorized Fetch」モードを有効にしている。HTTP署名のないリクエストをすべて拒否するモードだ。
この機能が生まれた背景には、ActivityPubの構造的な課題がある。Mastodon Issue #9849では「ActivityPub's dirty secret」として、オブジェクトが作成者の意図しない形で漏洩する複数の経路が指摘されている。ユーザーは自分のデータがどのサーバーに共有されるかを完全にコントロールしたいが、認証なしのオブジェクトフェッチを許可するとそれが難しくなる。
Authorized Fetchは完全なセキュリティソリューションではない。Issue #18353の議論では、「ブロック対象のサーバーから他のサーバー経由でリクエストを迂回する技術も存在する」といった限界が指摘されている。それでも、悪意あるアクターへの障壁を高める効果はある。
ioriがAuthorized Fetchを有効にしているサーバーからActivityを受信する際、送信元アクター情報を取得しようとすると401エラーが返ってきた。解決策として「インスタンスアクター」パターンを実装した。
インスタンスアクターとは、サーバー自体を表す専用のアクター(Applicationオブジェクト)だ。Mastodon Issue #10453で議論され、2019年に実装された。すべての取得リクエストへの署名、リレー機能、連合レポートなどに使用される。
// 個人inboxではユーザーの鍵を使用、共有inboxではインスタンスアクターの鍵を使用
const documentLoader = await ctx.getDocumentLoader({
identifier: ctx.recipient ?? INSTANCE_ACTOR_IDENTIFIER,
});インスタンスアクター識別子の罠
最初、インスタンスアクターの識別子に~actorを使っていた。ところがMastodonからフォローしようとすると422 Unprocessable Entityが返ってくる。
原因はMastodonのaccount.rbで定義されているUSERNAME_RE正規表現だ。リモートユーザーのバリデーションに使われるこの正規表現は、チルダを許可していない。リモートサーバーがioriのインスタンスアクターをフェッチしようとした際、preferredUsernameのバリデーションで弾かれていた。
Issue #10453の議論では「preferredUsernameはMastodon互換である必要がある」「ドットを含むユーザー名はリモートユーザーには許可されている」と指摘されている。
// チルダはMastodonで使えない
// export const INSTANCE_ACTOR_IDENTIFIER = '~actor';
// Sharkey/Misskeyフォークで使われているパターンに変更
export const INSTANCE_ACTOR_IDENTIFIER = 'instance.actor';JSON-LD名前空間の問題
MastodonからのActivityを処理しようとすると、JSON-LDの解析でエラーが発生した。
jsonld.InvalidUrl: Dereferencing a URL did not result in a valid JSON-LD object.
url: 'http://joinmastodon.org/ns',
cause: FetchError: HTTP 404Mastodonは http://joinmastodon.org/ns# をJSON-LD名前空間として使用しているが、このURLは実際のJSON-LDコンテキストドキュメントを返さない。これはHacker Newsのスレッドで「fake namespace」と指摘されている。
この問題の背景には、JSON-LDコンテキストの配信に関する歴史がある。Issue #9411によると、Mastodonは以前、共通のJSON-LDコンテキストをバンドルしていた。しかしコミットd40ef112で削除された。「わずかなRAMの節約」が理由だったが、外部サーバー(w3id.org/identity/v1など)がダウンした際にキャッシュを持たないインスタンスが機能停止に陥る問題が発生した。
解決策として、カスタムコンテキストローダーを実装し、問題のある名前空間をローカルで事前定義した。
const PRELOADED_CONTEXTS: Record<string, object> = {
'http://joinmastodon.org/ns': {
'@context': {
'toot': 'http://joinmastodon.org/ns#',
'Emoji': 'toot:Emoji',
'featured': { '@id': 'toot:featured', '@type': '@id' },
'discoverable': 'toot:discoverable',
// ... 他のMastodon固有の語彙
},
},
'http://litepub.social/ns': {
'@context': {
'litepub': 'http://litepub.social/ns#',
'EmojiReact': 'litepub:EmojiReact',
},
},
};