https://github.com/dahlia/fedify-microblog-tutorial-ja
『自分だけのフェディバースのマイクロブログを作ろう!』のAsciiDocのソースコード
https://github.com/dahlia/fedify-microblog-tutorial-ja
fedify japanese microblog tutorial
Last synced: 7 months ago
JSON representation
『自分だけのフェディバースのマイクロブログを作ろう!』のAsciiDocのソースコード
- Host: GitHub
- URL: https://github.com/dahlia/fedify-microblog-tutorial-ja
- Owner: dahlia
- License: cc-by-sa-4.0
- Created: 2024-10-05T14:25:29.000Z (over 1 year ago)
- Default Branch: main
- Last Pushed: 2024-12-23T03:32:39.000Z (over 1 year ago)
- Last Synced: 2025-10-24T01:44:46.044Z (7 months ago)
- Topics: fedify, japanese, microblog, tutorial
- Language: Shell
- Homepage: https://github.com/dahlia/fedify-microblog-tutorial-ja/releases/download/2024-12-23/fedify-microblog-tutorial-ja.pdf
- Size: 5.68 MB
- Stars: 20
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.adoc
- License: LICENSE
Awesome Lists containing this project
README
= 自分だけのフェディバースのマイクロブログを作ろう!
:author: 洪 民憙(ホン・ミンヒ)
:email: hong@minhee.org
:doctype: book
:lang: ja
:scripts: cjk
:experimental:
:imagesdir: ./images
:toc:
:toc-title: 目次
:figure-caption: 図
:tip-caption: ヒント
:note-caption: 注釈
:important-caption: 重要
:caution-caption: 注意
:warning-caption: 警告
== 序文
[TIP]
====
本書はAsciiDocで組版されており、以下のGitHubリポジトリからソースコードとPDF本を入手できます:
====
このチュートリアルに入る前に、フェディバースとActivityPubについて簡単に説明しましょう。
=== フェディバースとは?
link:https://ja.wikipedia.org/wiki/Fediverse[フェディバース](fediverse)は、“federation”(連合)と“universe”(宇宙)を組み合わせた造語で、相互にやり取りができる分散型のソーシャルネットワークの集合体を指します。従来の中央集権型のソーシャルメディアプラットフォーム(例:X、Facebook)とは異なり、フェディバースは多数の独立したサーバー(インスタンス)で構成されています。
フェディバースの主な特徴:
- **分散型**:単一の企業や団体が管理するのではなく、世界中の個人や組織が運営する多数のサーバーで構成されています。
- **相互運用性**:異なるサーバー上のユーザーが相互にコミュニケーションを取ることができます。
- **オープンソース**:多くのフェディバースソフトウェアはオープンソースで、誰でも自由に使用・改変できます。
- **データの自己管理**:ユーザーは自分のデータをより直接的に管理できます。
フェディバースの代表的なソフトウェアには、link:https://joinmastodon.org/ja[Mastodon]、Metaのlink:https://www.threads.net/[Threads]、link:https://misskey-hub.net/ja/[Misskey]、link:https://pixelfed.org/[Pixelfed]などがあります。
=== ActivityPubとは?
link:https://activitypub.rocks/[ActivityPub]は、フェディバースを支える重要な技術標準の一つです。これは、ソーシャルネットワーキングプロトコルであり、異なるサーバー間でのアクティビティ(投稿、コメント、いいね等)の共有方法を定義しています。
ActivityPubの主な特徴:
- **W3C勧告**:World Wide Web Consortium(W3C)によって標準化されています。
- **クライアント-サーバー通信**:ユーザーのクライアントアプリとサーバー間の通信を定義します。
- **サーバー間通信**:異なるサーバー間でのアクティビティの配信方法を規定します。
- **JSON-LD形式**:データはJSON for Linked Data (JSON-LD)形式で表現されます。
ActivityPubを採用することで、異なるソフトウェア間での相互運用性が実現され、ユーザーは自分の選んだサービスを使いながら、他のサービスのユーザーとコミュニケーションを取ることができます。
このチュートリアルでは、ActivityPubサーバーフレームワークであるlink:https://fedify.dev/[Fedify]を使用して、link:https://joinmastodon.org/ja[Mastodon]やlink:https://misskey-hub.net/ja/[Misskey]のようなActivityPubプロトコルを実装するlink:https://ja.wikipedia.org/wiki/%E3%83%9F%E3%83%8B%E3%83%96%E3%83%AD%E3%82%B0[マイクロブログ](microblog)を作成します。 このチュートリアルは、Fedifyの基本的な動作原理を理解するよりも、Fedifyの活用方法により焦点を当てています。
=== Fedifyとは?
.Fedifyのロゴ
image::logo.svg[Fedifyのロゴ,width=50%,align=center]
link:https://fedify.dev/[Fedify]は、ActivityPubやその他の標準規格を利用した連合サーバーアプリを作る為のTypeScriptライブラリです。 連合サーバーアプリを作る際の複雑さやボイラプレートコードを排除し、ビジネスロジックやユーザーエクスペリエンスに集中できる様にすることを目的としています。
Fedifyプロジェクトについてもっとお知りになりたい方は、以下の資料をご覧ください:
- **ウェブサイト**:
- **GitHub**:
- **APIリファレンス**:
- **使用例**:
ご質問、ご提案、フィードバックなどございましたら、お気軽にlink:https://github.com/dahlia/fedify/discussions[GitHub Discussions]にご参加いただくか、フェディバースのlink:https://hollo.social/@fedify[@fedify@hollo.social](日本語対応)までご連絡ください!
=== 対象読者
このチュートリアルは、Fedifyを学んでActivityPubサーバーソフトウェアを作ってみたい方を対象としています。
HTMLやHTTPを使用してウェブアプリケーションを作成した経験があり、コマンドラインインターフェース、SQL、JSON、基本的なJavaScriptなどを理解していることを前提としています。 ただし、TypeScriptやJSX、ActivityPub、Fedifyについては、このチュートリアルで必要な範囲で説明しますので、知らなくても大丈夫です。
ActivityPubソフトウェアを作成した経験は必要ありませんが、MastodonやMisskeyのようなActivityPubソフトウェアを少なくとも1つは使用したことがあることを想定しています。 そうすることで、私たちが何を作ろうとしているのかをイメージしやすくなります。
=== 目標
このチュートリアルでは、Fedifyを使用してActivityPubを通じて他の連合ソフトウェアやサービスと通信可能な個人用マイクロブログを作成します。このソフトウェアには以下の機能が含まれます:
- ユーザーは1つのアカウントのみ作成できます。
- フェディバース内の他のアカウントがユーザーをフォローできます。
- フォロワーはユーザーのフォローを開始したり、やめたりできます。
- ユーザーは自分のフォロワーリストを閲覧できます。
- ユーザーは投稿を作成できます。
- ユーザーの投稿はフェディバース内のフォロワーに表示されます。
- ユーザーはフェディバース内の他のアカウントをフォローできます。
- ユーザーは自分がフォローしているアカウントのリストを閲覧できます。
- ユーザーは自分がフォローしているアカウントが作成した投稿を時系列順のリストで閲覧できます。
チュートリアルを単純化するために、以下の機能制約を設けています:
- アカウントプロフィール(自己紹介文、画像など)は設定できません。
- 一度作成したアカウントは削除できません。
- 一度投稿した内容は編集や削除ができません。
- 一度フォローした他のアカウントのフォローを解除することはできません。
- いいね、共有、コメント機能はありません。
- 検索機能はありません。
- 認証や権限チェックなどのセキュリティ機能はありません。
もちろん、チュートリアルを最後まで進めた後で機能を追加することは自由です。それは良い練習になるでしょう。
完成したソースコードはlink:https://github.com/dahlia/microblog[GitHubリポジトリ]にアップロードされており、各実装段階に応じてコミットが分かれていますので、参考にしてください。
== 開発環境のセットアップ
=== Node.jsのインストール
FedifyはJavaScriptランタイムとしてlink:https://deno.com/[Deno]、link:https://bun.sh/[Bun]、link:https://nodejs.org/[Node.js]の3つをサポートしています。その中でもNode.jsが最も広く使われているため、このチュートリアルではNode.jsを基準に説明を進めていきます。
TIP: JavaScriptランタイムとは、JavaScriptコードを実行するプラットフォームのことです。ウェブブラウザもJavaScriptランタイムの一つであり、コマンドラインやサーバーではNode.jsなどが広く使われています。最近ではlink:https://workers.cloudflare.com/[Cloudflare Workers]のようなクラウドエッジ機能もJavaScriptランタイムの一つとして注目を集めています。
Fedifyを使用するにはNode.js 20.0.0以上のバージョンが必要です。link:https://nodejs.org/ja/download/package-manager[様々なインストール方法]がありますので、自分に最適な方法でNode.jsをインストールしてください。
Node.jsがインストールされると、``node``コマンドと``npm``コマンドが使えるようになります:
[source,console]
----
$ node --version
$ npm --version
----
=== ``fedify``コマンドのインストール
Fedifyプロジェクトをセットアップするために、``fedify``コマンドをシステムにインストールする必要があります。link:https://fedify.dev/cli#installation[複数のインストール方法]がありますが、``npm``コマンドを使用するのが最も簡単です:
[source,console]
----
$ npm install -g @fedify/cli
----
インストールが完了したら、``fedify``コマンドが使用可能かどうか確認しましょう。以下のコマンドで``fedify``コマンドのバージョンを確認できます。
[source,console]
----
$ fedify --version
----
表示されたバージョン番号が1.0.0以上であることを確認してください。それより古いバージョンだと、このチュートリアルを正しく進めることができません。
=== ``fedify init``でプロジェクトの初期化
新しいFedifyプロジェクトを開始するために、作業ディレクトリのパスを決めましょう。このチュートリアルでは__microblog__と名付けることにします。``fedify init``コマンドの後にディレクトリパスを指定して実行します(ディレクトリがまだ存在しなくても大丈夫です):
[source,console]
----
$ fedify init microblog
----
``fedify init``コマンドを実行すると、以下のような質問プロンプトが表示されます。順番にmenu:Node.js[npm > Hono > In-memory > In-process]を選択します:
[listing]
----
___ _____ _ _ __
/'_') | ___|__ __| (_)/ _|_ _
.-^^^-/ / | |_ / _ \/ _` | | |_| | | |
__/ / | _| __/ (_| | | _| |_| |
<__.|_|-|_| |_| \___|\__,_|_|_| \__, |
|___/
? Choose the JavaScript runtime to use
Deno
Bun
❯ Node.js
? Choose the package manager to use
❯ npm
Yarn
pnpm
? Choose the web framework to integrate Fedify with
Bare-bones
Fresh
❯ Hono
Express
Nitro
? Choose the key-value store to use for caching
❯ In-memory
Redis
PostgreSQL
Deno KV
? Choose the message queue to use for background jobs
❯ In-process
Redis
PostgreSQL
Deno KV
----
NOTE: Fedifyはフルスタックフレームワークではなく、ActivityPubサーバーの実装に特化したフレームワークです。したがって、他のウェブフレームワークと一緒に使用することを前提に設計されています。このチュートリアルでは、ウェブフレームワークとしてlink:https://hono.dev/[Hono]を採用し、Fedifyと共に使用します。
しばらくすると、作業ディレクトリ内に以下のような構造でファイルが生成されるのが確認できます:
* _.vscode/_ — Visual Studio Code関連の設定
** _extensions.json_ — Visual Studio Code推奨拡張機能
** _settings.json_ — Visual Studio Code設定
* _node_modules/_ — 依存パッケージがインストールされるディレクトリ(内容省略)
* _src/_ — ソースコード
** _app.tsx_ — ActivityPubと関係ないサーバー
** _federation.ts_ — ActivityPubサーバー
** _index.ts_ — エントリーポイント
** _logging.ts_ — ロギング設定
* _biome.json_ — フォーマッターおよびリント設定
* _package.json_ — パッケージメタデータ
* _tsconfig.json_ — TypeScript設定
想像できると思いますが、JavaScriptではなくTypeScriptを使用するため、__.js__ファイルではなく__.ts__および__.tsx__ファイルがあります。
生成されたソースコードは動作するデモです。まずはこの状態で正常に動作するか確認しましょう:
[source,console]
$ npm run dev
上記のコマンドを実行すると、kbd:[Ctrl+C]キーを押すまでサーバーが実行されたままになります:
[listing]
Server started at http://0.0.0.0:8000
サーバーが実行された状態で、新しいターミナルタブを開き、以下のコマンドを実行します:
[source,console]
$ fedify lookup http://localhost:8000/users/john
上記のコマンドは、ローカルで起動したActivityPubサーバーの1つのアクター(actor)を照会したものです。ActivityPubにおいて、アクターは様々なActivityPubサーバー間でアクセス可能なアカウントだと考えてください。
以下のような結果が出力されれば正常です:
[listing]
✔ Looking up the object...
Person {
id: URL "http://localhost:8000/users/john",
name: "john",
preferredUsername: "john"
}
この結果から、__/users/john__パスに位置するアクターオブジェクトの種類が``Person``であり、そのIDが__http://localhost:8000/users/john__、名前が__john__、ユーザー名も__john__であることがわかります。
[TIP]
====
``fedify lookup``はActivityPubオブジェクトを照会するコマンドです。これはMastodonで該当URIを検索するのと同じ動作をします。(もちろん、現在皆さんのサーバーはローカルでのみアクセス可能なため、まだMastodonで検索しても結果は表示されません)
``fedify lookup``コマンドよりも``curl``を好む場合は、以下のコマンドでもアクター照会が可能です(``-H``オプションで``Accept``ヘッダーを一緒に送信することに注意してください):
[source,console]
$ curl -H"Accept: application/activity+json" http://localhost:8000/users/john
ただし、上記のように照会すると、その結果は人間の目で確認しにくいJSON形式になります。システムに``jq``コマンドもインストールされている場合は、``curl``と``jq``を組み合わせて使用することもできます:
[source,console]
$ curl -H"Accept: application/activity+json" http://localhost:8000/users/john | jq .
====
=== Visual Studio Code
https://code.visualstudio.com/[Visual Studio Code]が皆さんのお気に入りのエディタでない可能性もあります。しかし、このチュートリアルを進める間はVisual Studio Codeを使用することをお勧めします。なぜなら、TypeScriptを使用する必要があり、Visual Studio Codeは現存する最も便利で優れたTypeScript IDEだからです。また、生成されたプロジェクトセットアップにはすでにVisual Studio Codeの設定が整っているため、フォーマッターやリントなどと格闘する必要もありません。
CAUTION: Visual Studioと混同しないようにしてください。Visual Studio CodeとVisual Studioはブランドを共有しているだけで、まったく異なるソフトウェアです。
https://code.visualstudio.com/docs/setup/setup-overview[Visual Studio Codeをインストール]した後、menu:ファイル[フォルダを開く…]メニューをクリックして作業ディレクトリを読み込んでください。
右下に「このリポジトリ 用のおすすめ拡張機能 'Biome' 拡張機能 提供元: biomejs をインストールしますか?」と尋ねるウィンドウが表示された場合は、btn:[インストール]ボタンをクリックしてその拡張機能をインストールしてください。この拡張機能をインストールすると、TypeScriptコードを作成する際にインデントや空白など、コードスタイルと格闘する必要がなく、自動的にコードがフォーマットされます。
TIP: 熱心なEmacsまたはVimユーザーの場合、使い慣れたお気に入りのエディタを使用することを止めはしません。ただし、TypeScript LSPの設定は確認しておくことをお勧めします。TypeScript LSPの設定の有無により、生産性に大きな差が出るからです。
== 予備知識
=== TypeScript
コードを修正する前に、簡単にTypeScriptについて触れておきましょう。すでにTypeScriptに慣れている方は、この章をスキップしても構いません。
TypeScriptはJavaScriptに静的型チェックを追加したものです。TypeScriptの文法はJavaScriptの文法とほぼ同じですが、変数や関数の文法に型を指定できるという大きな違いがあります。型指定は変数やパラメータの後にコロン(`:`)をつけて表します。
例えば、次のコードは``foo``変数が文字列(`string`)であることを示しています:
[source,typescript]
let foo: string;
上記のように宣言された``foo``変数に文字列以外の型の値を代入しようとすると、Visual Studio Codeが**実行する前に**赤い下線を引いて型エラーを表示します:
[source,typescript]
----
foo = 123; // <1>
----
<1> ts(2322): 型``number``を型``string``に割り当てることはできません。
コーディング中に赤い下線が表示されたら、無視せずに対処してください。無視してプログラムを実行すると、その部分で実際にエラーが発生する可能性が高いです。
TypeScriptでコーディングをしていて最も頻繁に遭遇する型エラーは、``null``の可能性があるエラーです。例えば、次のコードでは``bar``変数が文字列(`string`)である可能性もあれば``null``である可能性もある(`string | null`)と示されています:
[source,typescript]
const bar: string | null = someFunction();
この変数の内容から最初の文字を取り出そうとして、次のようなコードを書くとどうなるでしょうか:
[source,typescript]
----
const firstChar = bar.charAt(0); // <1>
----
<1> ts(18047): ``bar``は``null``の可能性があります。
上記のように型エラーが発生します。``bar``が場合によっては``null``である可能性があり、その場合に``null.charAt(0)``を呼び出すとエラーが発生する可能性があるため、コードを修正するよう指摘しています。このような場合、以下のように``null``の場合の処理を追加する必要があります:
[source,typescript]
const firstChar = bar === null ? "" : bar.charAt(0);
このように、TypeScriptはコーディング時に気づかなかった場合の数を想起させ、バグを未然に防ぐのに役立ちます。
また、TypeScriptの副次的な利点の一つは、自動補完が機能することです。例えば、``foo.``まで入力すると、文字列オブジェクトが持つメソッドのリストが表示され、その中から選択できます。これにより、一々ドキュメントを確認しなくても迅速にコーディングが可能になります。
このチュートリアルを進めながら、TypeScriptの魅力も一緒に感じていただければと思います。何より、FedifyはTypeScriptと一緒に使用したときに最も良い体験が得られるのです。
TIP: TypeScriptをしっかりじっくり学びたい場合は、公式のlink:https://www.typescriptlang.org/docs/handbook/intro.html[TypeScriptハンドブック](英語)を読むことをお勧めします。全部読むのに約30分ほどかかります。
=== JSX
JSXはJavaScriptコード内にXMLまたはHTMLを挿入できるようにするJavaScriptの文法拡張です。TypeScriptでも使用でき、その場合はTSXと呼ぶこともあります。このチュートリアルでは、すべてのHTMLをJSX文法を通じてJavaScriptコード内に記述します。JSXにすでに慣れている方は、この章をスキップして構いません。
例えば、以下のコードは``
``要素が最上位にあるHTMLツリーを``html``変数に代入します:
[source,tsx]
const html =
こんにちは、JSX!
;
中括弧を使用してJavaScript式を挿入することも可能です(以下のコードは、もちろん``getName()``関数が存在すると仮定しています):
[source,tsx]
const html =
こんにちは、{getName()}!
;
JSXの特徴の1つは、コンポーネント(component)と呼ばれる独自のタグを定義できることです。コンポーネントは普通のJavaScript関数として定義できます。例えば、以下のコードは````コンポーネントを定義して使用する方法を示しています(コンポーネント名は一般的にPascalCaseスタイルに従います):
[source,tsx]
----
import type { FC } from "hono/jsx";
function getName() {
return "JSX";
}
interface ContainerProps {
name: string;
}
const Container: FC = (props) => {
return
{props.children};
};
const html =
こんにちは、{getName()}!
;
----
上記のコードで``FC``は、我々が使用するウェブフレームワークであるlink:https://hono.dev/[Hono]が提供するもので、コンポーネントの型を定義するのに役立ちます。``FC``はlink:https://www.typescriptlang.org/docs/handbook/2/generics.html[ジェネリック型](generic type)で、``FC``のように山括弧内に入る型が型引数です。ここでは型引数としてプロップ(props)の形式を指定しています。プロップとは、コンポーネントに渡すパラメータのことを指します。上記のコードでは、````コンポーネントのプロップ形式として``ContainerProps``インターフェースを宣言して使用しています。
[NOTE]
====
ジェネリック型の型引数は複数になる場合があり、カンマで各引数を区切ります。例えば、``Foo``はジェネリック型``Foo``に型引数``A``と``B``を適用したものです。
また、ジェネリック関数というものもあり、``someFunction(foo, bar)``のように表記します。
型引数が1つの場合、型引数を囲む山括弧がXML/HTMLタグのように見えますが、JSXの機能とは無関係です。
- `FC`:ジェネリック型``FC``に型引数``ContainerProps``を適用したもの。
- ``:````という名前のコンポーネントタグを開いたもの。````で閉じる必要があります。
====
プロップとして渡されるもののうち、``children``は特に注目する必要があります。これはコンポーネントの子要素が``children``プロップとして渡されるためです。結果として、上記のコードで``html``変数には``
こんにちは、JSX!
``というHTMLツリーが代入されることになります。
TIP: JSXはReactプロジェクトで発明され、広く使用され始めました。JSXについて詳しく知りたい場合は、Reactのドキュメントのlink:https://ja.react.dev/learn/writing-markup-with-jsx[JSXでマークアップを記述する]およびlink:https://ja.react.dev/learn/javascript-in-jsx-with-curly-braces[JSXに波括弧でJavaScriptを含める]セクションを読んでみてください。
== アカウント作成ページ
さて、本格的な開発に取り掛かりましょう。
最初に作成するのはアカウント作成ページです。アカウントを作成しないと投稿もできず、他のアカウントをフォローすることもできませんからね。まずは見える部分から作り始めましょう。
まず、__src/views.tsx__ファイルを作成します。そして、そのファイル内にJSXで````コンポーネントを定義します:
[source,tsx]
----
import type { FC } from "hono/jsx";
export const Layout: FC = (props) => (
Microblog
{props.children}
);
----
デザインに多くの時間を費やさないために、link:https://picocss.com/[Pico CSS]というCSSフレームワークを使用することにします。
TIP: 変数やパラメータの型をTypeScriptの型チェッカーが推論できる場合、上記の``props``のように型表記を省略しても問題ありません。このように型表記が省略されている場合でも、Visual Studio Codeで変数名にマウスカーソルを合わせると、その変数がどの型であるかを確認できます。
次に、同じファイル内でレイアウトの中に入る````コンポーネントを定義します:
[source,tsx]
----
export const SetupForm: FC = () => (
<>
Set up your microblog
Username{" "}
>
);
----
JSXでは最上位に1つの要素しか置けませんが、````コンポーネントでは``
``と``
``の2つの要素を最上位に置いています。そのため、これを1つの要素のようにまとめるために、空のタグの形の``<>``と``>``で囲んでいます。これをフラグメント(fragment)と呼びます。
定義したコンポーネントを組み合わせて使用する番です。__src/app.tsx__ファイルで、先ほど定義した2つのコンポーネントを``import``します:
[source,typescript]
import { Layout, SetupForm } from "./views.tsx";
そして、__/setup__ページで先ほど作成したアカウント作成フォームを表示します:
[source,tsx]
app.get("/setup", (c) =>
c.html(
,
),
);
さて、それではウェブブラウザでページを開いてみましょう。以下のような画面が表示されれば正常です:
.アカウント作成ページ
image::account-creation-page.png[アカウント作成ページ,align=center]
NOTE: JSXを使用するには、ソースファイルの拡張子が__.jsx__または__.tsx__である必要があります。この章で編集した2つのファイルの拡張子がどちらも__.tsx__であることに注意してください。
=== データベースのセットアップ
さて、見える部分を実装したので、次は動作を実装する番です。アカウント情報を保存する場所が必要ですが、link:https://www.sqlite.org/[SQLite]を使用することにしましょう。SQLiteは小規模なアプリケーションに適したリレーショナルデータベースです。
まずはアカウント情報を格納するテーブルを定義しましょう。今後、すべてのテーブル定義は__src/schema.sql__ファイルに記述することにします。アカウント情報は``users``テーブルに格納します:
[source,sql]
----
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL UNIQUE CHECK (trim(lower(username)) = username
AND username <> ''
AND length(username) <= 50)
);
----
我々が作成するマイクロブログは1つのアカウントしか作成できないので、主キーである``id``カラムが``1``以外の値を許可しないように制約をかけました。これにより、``users``テーブルには2つ以上のレコードを格納できなくなります。また、アカウントIDを格納する``username``カラムが空の文字列や長すぎる文字列を許可しないように制約を設けました。
では、``users``テーブルを作成するために__src/schema.sql__ファイルを実行する必要があります。そのためには``sqlite3``コマンドが必要ですが、link:https://www.sqlite.org/download.html[SQLiteのウェブサイトからダウンロードするか]、各プラットフォームのパッケージマネージャーでインストールできます。macOSの場合は、オペレーティングシステムに組み込まれているので、別途ダウンロードする必要はありません。直接ダウンロードする場合は、オペレーティングシステムに合った__sqlite-tools-*.zip__ファイルをダウンロードして解凍してください。パッケージマネージャーを使用する場合は、次のコマンドでインストールすることもできます:
[source,console]
----
$ sudo apt install sqlite3 # <1>
$ sudo dnf install sqlite # <2>
> choco install sqlite # <3>
> scoop install sqlite # <4>
> winget install SQLite.SQLite # <5>
----
<1> DebianおよびUbuntu
<2> FedoraおよびRHEL
<3> Chocolatey
<4> Scoop
<5> Windows Package Manager
さて、``sqlite3``コマンドの準備ができたら、これを使用してデータベースファイルを作成しましょう:
[source,console]
$ sqlite3 microblog.sqlite3 < src/schema.sql
上記のコマンドを実行すると__microblog.sqlite3__ファイルが作成され、この中にSQLiteデータが保存されます。
=== アプリからデータベースに接続
これで、私たちが作成するアプリからSQLiteデータベースを使用するだけになりました。Node.jsでSQLiteデータベースを使用するには、SQLiteドライバーライブラリが必要です。ここではlink:https://github.com/WiseLibs/better-sqlite3[better-sqlite3]パッケージを使用することにします。パッケージは``npm``コマンドで簡単にインストールできます:
[source,console]
$ npm add better-sqlite3
$ npm add --save-dev @types/better-sqlite3
[TIP]
====
https://www.npmjs.com/package/@types/better-sqlite3[@types/better-sqlite3]パッケージは、TypeScript用にbetter-sqlite3パッケージのAPIに関する型情報を含んでいます。このパッケージをインストールすることで、Visual Studio Codeで編集する際に自動補完や型チェックが可能になります。
このように、@types/スコープ内にあるパッケージをlink:https://github.com/DefinitelyTyped/DefinitelyTyped[Definitely Typed]パッケージと呼びます。あるライブラリがTypeScriptで書かれていない場合、コミュニティが型情報を追加して作成したパッケージです。
====
パッケージをインストールしたので、このパッケージを使用してデータベースに接続するコードを書きましょう。__src/db.ts__という新しいファイルを作成し、以下のようにコーディングします:
[source,typescript]
----
import Database from "better-sqlite3";
const db = new Database("microblog.sqlite3");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
export default db;
----
[TIP]
====
参考までに、``db.pragma()``関数を通じて設定した内容は以下のような効果があります:
- https://www.sqlite.org/wal.html[`journal_mode = WAL`]:SQLiteでアトミックなコミットとロールバックを実装する方法としてlink:https://ja.wikipedia.org/wiki/%E3%83%AD%E3%82%B0%E5%85%88%E8%A1%8C%E6%9B%B8%E3%81%8D%E8%BE%BC%E3%81%BF[ログ先行書き込み]モードを採用します。このモードは、デフォルトのlink:https://www.sqlite.org/lockingv3.html#rollback[ロールバックジャーナル]モードに比べて、ほとんどの場合でパフォーマンスが優れています。
- https://www.sqlite.org/foreignkeys.html[`foreign_keys = ON`]:SQLiteではデフォルトで外部キー制約をチェックしません。この設定をオンにすると外部キー制約をチェックするようになり、データの整合性を保つのに役立ちます。
====
そして、``users``テーブルに保存されるレコードをJavaScriptで表現する型を宣言しましょう。__src/schema.ts__ファイルを作成し、以下のように``User``型を定義します:
[source,typescript]
export interface User {
id: number;
username: string;
}
=== レコードの挿入
データベースに接続したので、レコードを挿入する番です。
まず__src/app.tsx__ファイルを開き、レコード挿入に使用する``db``オブジェクトと``User``型を``import``します:
[source,typescript]
import db from "./db.ts";
import type { User } from "./schema.ts";
``POST /setup``ハンドラを実装します:
[source,typescript]
----
app.post("/setup", async (c) => {
// アカウントが既に存在するか確認
const user = db.prepare("SELECT * FROM users LIMIT 1").get();
if (user != null) return c.redirect("/");
const form = await c.req.formData();
const username = form.get("username");
if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
return c.redirect("/setup");
}
db.prepare("INSERT INTO users (username) VALUES (?)").run(username);
return c.redirect("/");
});
----
先ほど作成した``GET /setup``ハンドラにもアカウントが既に存在するかチェックするコードを追加します:
[source,tsx,highlight=2..4]
----
app.get("/setup", (c) => {
// アカウントが既に存在するか確認
const user = db.prepare("SELECT * FROM users LIMIT 1").get();
if (user != null) return c.redirect("/");
return c.html(
,
);
});
----
=== テスト
これでアカウント作成機能がひととおり実装されたので、実際に使ってみましょう。ウェブブラウザでページを開いてアカウントを作成してください。このチュートリアルでは、これ以降、ユーザー名として__johndoe__を使用したと仮定します。作成できたら、SQLiteデータベースにレコードが正しく挿入されたか確認もしてみましょう:
[source,console]
$ echo "SELECT * FROM users;" | sqlite3 -table microblog.sqlite3
レコードが正しく挿入されていれば、以下のような出力が表示されるはずです(もちろん、``johndoe``は皆さんが入力したユーザー名によって異なります):
[cols="1,1"]
|===
| `id` | `username`
| `1`
| `johndoe`
|===
== プロフィールページ
これでアカウントが作成されたので、アカウント情報を表示するプロフィールページを実装しましょう。表示する情報はほとんどありませんが。
今回も見える部分から作業を始めましょう。__src/views.tsx__ファイルに````コンポーネントを定義します:
[source,tsx]
----
export interface ProfileProps {
name: string;
handle: string;
}
export const Profile: FC = ({ name, handle }) => (
<>
{name}
{handle}
>
);
----
そして__src/app.tsx__ファイルで定義したコンポーネントを``import``します:
[source,typescript]
import { Layout, Profile, SetupForm } from "./views.tsx";
そして````コンポーネントを表示する``GET /users/{username}``ハンドラを追加します:
[source,tsx]
----
app.get("/users/:username", async (c) => {
const user = db
.prepare("SELECT * FROM users WHERE username = ?")
.get(c.req.param("username"));
if (user == null) return c.notFound();
const url = new URL(c.req.url);
const handle = `@${user.username}@${url.host}`;
return c.html(
,
);
});
----
ここまでできたらテストをしてみましょう。ウェブブラウザでページを開いてみてください(アカウント作成時にユーザー名を``johndoe``にした場合。そうでない場合はURLを変更する必要があります)。以下のような画面が表示されるはずです:
.プロフィールページ
image::profile-page.png[プロフィールページ,align=center]
[TIP]
====
フェディバースハンドル(fediverse handle)、略してハンドルとは、フェディバース内でアカウントを指す一意なアドレスのようなものです。例えば``+@hongminhee@hollo.social+``のような形をしています。メールアドレスに似た形をしていますが、実際の構成もメールアドレスに似ています。最初に``@``が来て、その後に名前、そして再び``@``が来た後、最後にアカウントが属するサーバーのドメイン名が来ます。時々、最初の``@``が省略されることもあります。
技術的には、ハンドルはlink:https://datatracker.ietf.org/doc/html/rfc7033[WebFinger]とlink:https://datatracker.ietf.org/doc/html/rfc7565[`acct:` URI形式]という2つの標準で実装されています。Fedifyがこれを実装しているため、このチュートリアルを進める間は実装の詳細を知らなくても大丈夫です。
====
== アクターの実装
ActivityPubは、その名前が示すように、アクティビティ(activity)を送受信するプロトコルです。投稿、投稿の編集、投稿の削除、投稿へのいいね、コメントの追加、プロフィールの編集…ソーシャルメディアで起こるすべての出来事をアクティビティとして表現します。
そして、すべてのアクティビティはアクター(actor)からアクターへ送信されます。例えば、山田太郎が投稿を作成すると、「投稿作成」(`Create(Note)`)アクティビティが山田太郎から山田太郎のフォロワーたちに送信されます。その投稿に佐藤花子がいいねをすると、「いいね」(`Like`)アクティビティが佐藤花子から山田太郎に送信されます。
したがって、ActivityPubを実装する最初のステップはアクターを実装することです。
``fedify init``コマンドで生成されたデモアプリには既にとてもシンプルなアクターが実装されていますが、MastodonやMisskeyなどの実際のソフトウェアと通信するためには、アクターをもう少しきちんと実装する必要があります。
まずは、現在の実装を一度見てみましょう。__src/federation.ts__ファイルを開いてみましょう:
[source,typescript,highlight=12..18]
----
import { Person, createFederation } from "@fedify/fedify";
import { InProcessMessageQueue, MemoryKvStore } from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";
const logger = getLogger("microblog");
const federation = createFederation({
kv: new MemoryKvStore(),
queue: new InProcessMessageQueue(),
});
federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: identifier,
name: identifier,
});
});
export default federation;
----
注目すべき部分は``setActorDispatcher()``メソッドです。このメソッドは、他のActivityPubソフトウェアが我々が作成したサーバーのアクターを照会する際に使用するURLとその動作を定義します。例えば、先ほど我々が行ったように__/users/johndoe__を照会すると、コールバック関数の``identifier``パラメータに``"johndoe"``という文字列値が入ってきます。そして、コールバック関数は``Person``クラスのインスタンスを返して、照会されたアクターの情報を伝達します。
``ctx``パラメータには``Context``オブジェクトが渡されますが、これはActivityPubプロトコルに関連する様々な機能を含むオブジェクトです。例えば、上記のコードで使用されている``getActorUri()``メソッドは、パラメータとして渡された``identifier``を含むアクターの一意なURIを返します。このURIは``Person``オブジェクトの一意な識別子として使用されています。
実装コードを見ればわかるように、現在は__/users/__パスの後にどのようなハンドルが来ても、呼び出されたままのアクター情報を**作り出して**返しています。しかし、我々が望むのは実際に登録されているアカウントについてのみ照会できるようにすることです。この部分をデータベースに存在するアカウントについてのみ返すように修正しましょう。
=== テーブルの作成
``actors``テーブルを作成する必要があります。このテーブルは、現在のインスタンスサーバーのアカウントのみを含む``users``テーブルとは異なり、連合されるサーバーに属するリモートアクターも含みます。テーブルは次のようになります。__src/schema.sql__ファイルに次のSQLを追加してください:
[source,sql]
----
CREATE TABLE IF NOT EXISTS actors (
id INTEGER NOT NULL PRIMARY KEY,
user_id INTEGER REFERENCES users (id), -- <1>
uri TEXT NOT NULL UNIQUE CHECK (uri <> ''), -- <2>
handle TEXT NOT NULL UNIQUE CHECK (handle <> ''), -- <3>
name TEXT, -- <4>
inbox_url TEXT NOT NULL UNIQUE CHECK (inbox_url LIKE 'https://%' -- <5>
OR inbox_url LIKE 'http://%'),
shared_inbox_url TEXT CHECK (shared_inbox_url -- <6>
LIKE 'https://%'
OR shared_inbox_url
LIKE 'http://%'),
url TEXT CHECK (url LIKE 'https://%' -- <7>
OR url LIKE 'http://%'),
created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) -- <8>
CHECK (created <> '')
);
----
<1> ``user_id``カラムは``users``カラムと連携するための外部キーです。該当レコードがリモートアクターを表す場合は``NULL``が入りますが、現在のインスタンスサーバーのアカウントの場合は該当アカウントの``users.id``値が入ります。
<2> ``uri``カラムはアクターIDと呼ばれるアクターの一意なURIを含みます。アクターを含むすべてのActivityPubオブジェクトはURI形式の一意なIDを持ちます。したがって、空にすることはできず、重複もできません。
<3> ``handle``カラムは``+@johndoe@example.com+``形式のフェディバースハンドルを含みます。同様に、空にすることはできず、重複もできません。
<4> ``name``カラムはUIに表示される名前を含みます。通常はフルネームやニックネームが入ります。ただし、ActivityPub仕様に従い、このカラムは空になる可能性があります。
<5> ``inbox_url``カラムは該当アクターのインボックス(inbox)URLを含みます。インボックスが何であるかについては後で詳しく説明しますが、現時点ではアクターに必須で存在しなければならないということだけ覚えておいてください。このカラムも空にすることはできず、重複もできません。
<6> ``shared_inbox_url``カラムは該当アクターの共有インボックス(shared inbox)URLを含みます。これについても後で詳しく説明します。必須ではないため、空になる可能性があり、カラム名の通り他のアクターと同じ共有インボックスURLを共有することもできます。
<7> ``url``カラムは該当アクターのプロフィールURLを含みます。プロフィールURLとは、ウェブブラウザで開いて見ることができるプロフィールページのURLを意味します。アクターのIDとプロフィールURLが同じ場合もありますが、サービスによって異なる場合もあるため、その場合にこのカラムにプロフィールURLを含めます。空になる可能性があります。
<8> ``created``カラムはレコードが作成された時点を記録します。空にすることはできず、デフォルトで挿入時点の時刻が記録されます。
さて、これで__src/schema.sql__ファイルを__microblog.sqlite3__データベースファイルに適用しましょう:
[source,console]
$ sqlite3 microblog.sqlite3 < src/schema.sql
TIP: 先ほど``users``テーブルを定義する際に``CREATE TABLE IF NOT EXISTS``文を使用したため、何度実行しても問題ありません。
そして、``actors``テーブルに保存されるレコードをJavaScriptで表現する型も__src/schema.ts__に定義します:
[source,typescript]
export interface Actor {
id: number;
user_id: number | null;
uri: string;
handle: string;
name: string | null;
inbox_url: string;
shared_inbox_url: string | null;
url: string | null;
created: string;
}
=== アクターレコード
現在``users``テーブルにレコードが1つありますが、これと対応するレコードが``actors``テーブルにはありません。アカウントを作成する際に``actors``テーブルにレコードを追加しなかったためです。アカウント作成コードを修正して``users``と``actors``の両方にレコードを追加するようにする必要があります。
まず__src/views.tsx__にある``SetupForm``で、ユーザー名と一緒に``actors.name``カラムに入れる名前も入力を受け付けるようにしましょう:
[source,tsx,highlight=16..18]
export const SetupForm: FC = () => (
<>
Set up your microblog
Username{" "}
Name
>
);
先ほど定義した``Actor``型を__src/app.tsx__で``import``します:
[source,typescript]
import type { Actor, User } from "./schema.ts";
これで入力された名前をはじめ、必要な情報を``actors``テーブルのレコードとして作成するコードを``POST /setup``ハンドラに追加します:
[source,typescript,highlight=7,19..24,26,30..44]
----
app.post("/setup", async (c) => {
// アカウントが既に存在するか確認
const user = db
.prepare(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
LIMIT 1
`,
)
.get();
if (user != null) return c.redirect("/");
const form = await c.req.formData();
const username = form.get("username");
if (typeof username !== "string" || !username.match(/^[a-z0-9_-]{1,50}$/)) {
return c.redirect("/setup");
}
const name = form.get("name");
if (typeof name !== "string" || name.trim() === "") {
return c.redirect("/setup");
}
const url = new URL(c.req.url);
const handle = `@${username}@${url.host}`;
const ctx = fedi.createContext(c.req.raw, undefined);
db.transaction(() => {
db.prepare("INSERT OR REPLACE INTO users (id, username) VALUES (1, ?)").run(
username,
);
db.prepare(
`
INSERT OR REPLACE INTO actors
(user_id, uri, handle, name, inbox_url, shared_inbox_url, url)
VALUES (1, ?, ?, ?, ?, ?, ?)
`,
).run(
ctx.getActorUri(username).href,
handle,
name,
ctx.getInboxUri(username).href,
ctx.getInboxUri().href,
ctx.getActorUri(username).href,
);
})();
return c.redirect("/");
});
----
アカウントが既に存在するかチェックする際、``users``テーブルにレコードがない場合だけでなく、対応するレコードが``actors``テーブルにない場合もまだアカウントが存在しないと判断するように修正しました。同じ条件を``GET /setup``ハンドラおよび``GET /users/{username}``ハンドラにも適用します:
[source,tsx,highlight=7]
----
app.get("/setup", (c) => {
// アカウントが既に存在するか確認
const user = db
.prepare(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
LIMIT 1
`,
)
.get();
if (user != null) return c.redirect("/");
return c.html(
,
);
});
----
[source,tsx,highlight=6]
----
app.get("/users/:username", async (c) => {
const user = db
.prepare(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
WHERE username = ?
`,
)
.get(c.req.param("username"));
if (user == null) return c.notFound();
const url = new URL(c.req.url);
const handle = `@${user.username}@${url.host}`;
return c.html(
,
);
});
----
TIP: TypeScriptでは``A & B``は``A``型と同時に``B``型であるオブジェクトを意味します。例えば、``{ a: number } & { b: string }``型があるとすると、``{ a: 123 }``や``{ b: "foo" }``はこの型を満たしませんが、``{ a: 123, b: "foo" }``はこの型を満たします。
最後に、__src/federation.ts__ファイルを開き、アクターディスパッチャーの下に次のコードを追加します:
[source,typescript]
federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
``setInboxListeners()``メソッドは今のところ気にしないでください。これもまたインボックスについて説明する際に一緒に扱うことにします。ただ、アカウント作成コードで使用した``getInboxUri()``メソッドが正しく動作するためには上記のコードが必要だという点だけ指摘しておきます。
コードをすべて修正したら、ブラウザでページを開いて再度アカウントを作成します:
.アカウント作成ページ
image::account-creation-page-2.png[アカウント作成ページ,align=center]
=== アクターディスパッチャー
``actors``テーブルを作成してレコードも追加したので、再び__src/federation.ts__ファイルを修正しましょう。まず``db``オブジェクトと``Endpoints``および``Actor``を``import``します:
[source,typescript]
import { Endpoints, Person, createFederation } from "@fedify/fedify";
import db from "./db.ts";
import type { Actor, User } from "./schema.ts";
必要なものを``import``したので``setActorDispatcher()``メソッドを修正しましょう:
[source,typescript,highlight=2..11,16..21]
----
federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
const user = db
.prepare(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
WHERE users.username = ?
`,
)
.get(identifier);
if (user == null) return null;
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: identifier,
name: user.name,
inbox: ctx.getInboxUri(identifier),
endpoints: new Endpoints({
sharedInbox: ctx.getInboxUri(),
}),
url: ctx.getActorUri(identifier),
});
});
----
変更されたコードでは、データベースの``users``テーブルを照会して現在のサーバーにあるアカウントでない場合は``null``を返すようになりました。つまり、``GET /users/johndoe``(アカウント作成時にユーザー名を``johndoe``にしたと仮定した場合)リクエストに対しては正しい``Person``オブジェクトを``200 OK``とともに応答し、それ以外のリクエストに対しては``404 Not Found``を応答することになります。
``Person``オブジェクトを生成する部分もどのように変わったか見てみましょう。まず``name``属性が追加されました。このプロパティは``actors.name``カラムの値を使用します。``inbox``と``endpoints``属性はインボックスについて説明するときに一緒に扱うことにします。``url``属性はこのアカウントのプロフィールURLを含みますが、このチュートリアルではアクターIDとアクターのプロフィールURLを一致させることにします。
[TIP]
====
目のいい方々は気づいたかもしれませんが、HonoとFedify両方で``GET /users/{identifier}``に対するハンドラを重複して定義しています。では、実際にそのリクエストを送信すると、どちらが応答することになるでしょうか?答えは、リクエストの``Accept``ヘッダーによって異なります。``Accept: text/html``ヘッダーと一緒にリクエストを送信すると、Hono側のリクエストハンドラが応答します。``Accept: application/activity+json``ヘッダーと一緒にリクエストを送信すると、Fedify側のリクエストハンドラが応答します。
このようにリクエストの``Accept``ヘッダーに応じて異なる応答を返す方式をHTTPのlink:https://developer.mozilla.org/ja/docs/Web/HTTP/Content_negotiation[コンテンツネゴシエーション](content negotiation)と呼び、Fedify自体がコンテンツネゴシエーションを実装しています。より具体的には、すべてのリクエストは一度Fedifyを通過し、ActivityPubに関連するリクエストでない場合は連携されたフレームワーク、このチュートリアルではHonoにリクエストを渡すようになっています。
====
TIP: FedifyではすべてのURIおよびURLをlink:https://developer.mozilla.org/ja/docs/Web/API/URL[`URL`]インスタンスで表現します。
=== テスト
それでは、アクターディスパッチャーをテストしてみましょう。
サーバーが起動している状態で、新しいターミナルタブを開いて以下のコマンドを入力します:
[source,console]
$ fedify lookup http://localhost:8000/users/alice
``alice``というアカウントが存在しないため、先ほどとは異なり、今度は次のようなエラーが発生するはずです:
[listing]
✔ Looking up the object...
Failed to fetch the object.
It may be a private object. Try with -a/--authorized-fetch.
では``johndoe``アカウントも照会してみましょう:
[source,console]
fedify lookup http://localhost:8000/users/johndoe
今度は結果がきちんと出力されます:
[listing]
✔ Looking up the object...
Person {
id: URL "http://localhost:8000/users/johndoe",
name: "John Doe",
url: URL "http://localhost:8000/users/johndoe",
preferredUsername: "johndoe",
inbox: URL "http://localhost:8000/users/johndoe/inbox",
endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}
== 暗号鍵ペア
次に実装するのは、署名のためのアクターの暗号鍵です。ActivityPubではアクターがアクティビティを作成して送信しますが、このときアクティビティを本当にそのアクターが作成したことを証明するためにlink:https://ja.wikipedia.org/wiki/%E3%83%87%E3%82%B8%E3%82%BF%E3%83%AB%E7%BD%B2%E5%90%8D[デジタル署名]を行います。そのために、アクターはペアになった自身だけの秘密鍵(private key)と公開鍵(public key)のペアを作成して持っており、その公開鍵を他のアクターも見られるように公開します。アクターはアクティビティを受信する際に、送信者の公開鍵とアクティビティの署名を検証して、そのアクティビティが本当に送信者が生成したものかどうかを確認します。署名と署名の検証はFedifyが自動的に行いますが、鍵ペアを生成して保存するのは直接実装する必要があります。
CAUTION: 秘密鍵は、その名前が示すように署名を行う主体以外はアクセスできないようにする必要があります。一方、公開鍵はその用途自体が公開することなので、誰でもアクセスしても問題ありません。
=== テーブルの作成
秘密鍵と公開鍵のペアを保存する``keys``テーブルを__src/schema.sql__に定義します:
[source,sql]
CREATE TABLE IF NOT EXISTS keys (
user_id INTEGER NOT NULL REFERENCES users (id),
type TEXT NOT NULL CHECK (type IN ('RSASSA-PKCS1-v1_5', 'Ed25519')),
private_key TEXT NOT NULL CHECK (private_key <> ''),
public_key TEXT NOT NULL CHECK (public_key <> ''),
created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> ''),
PRIMARY KEY (user_id, type)
);
テーブルをよく見ると、``type``カラムには2種類の値のみが許可されていることがわかります。一つはlink:https://www.rfc-editor.org/rfc/rfc2313[RSA-PKCS#1-v1.5]形式で、もう一つはlink:https://ed25519.cr.yp.to/[Ed25519]形式です。(それぞれが何を意味するかは、このチュートリアルでは重要ではありません)主キーが``(user_id, type)``にかかっているので、1ユーザーに対して最大二つの鍵ペアが存在できます。
TIP: このチュートリアルで詳しく説明することはできませんが、2024年9月現在、ActivityPubネットワークはRSA-PKCS#1-v1.5形式からEd25519形式に移行中であると知っておくと良いでしょう。あるソフトウェアはRSA-PKCS#1-v1.5形式のみを受け入れ、あるソフトウェアはEd25519形式を受け入れます。したがって、両方と通信するためには、二つの鍵ペアが両方とも必要になるのです。
``private_key``および``public_key``カラムは文字列を受け取れるようになっていますが、ここにはJSONデータを入れる予定です。秘密鍵と公開鍵をJSONでエンコードする方法については、後で順を追って説明します。
では``keys``テーブルを作成しましょう:
[source,console]
$ sqlite3 microblog.sqlite3 < src/schema.sql
``keys``テーブルに保存されるレコードをJavaScriptで表現する``Key``型も__src/schema.ts__ファイルに定義します:
[source,typescript]
export interface Key {
user_id: number;
type: "RSASSA-PKCS1-v1_5" | "Ed25519";
private_key: string;
public_key: string;
created: string;
}
=== 鍵ペアディスパッチャー
これで鍵ペアを生成して読み込むコードを書く必要があります。
__src/federation.ts__ファイルを開き、Fedifyが提供する``exportJwk()``、``generateCryptoKeyPair()``、``importJwk()``関数と先ほど定義した``Key``型を``import``しましょう:
[source,typescript,highlight=5..7,9]
----
import {
Endpoints,
Person,
createFederation,
exportJwk,
generateCryptoKeyPair,
importJwk,
} from "@fedify/fedify";
import type { Actor, Key, User } from "./schema.ts";
----
そしてアクターディスパッチャー部分を次のように修正します:
[source,typescript]
----
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
const user = db
.prepare(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
WHERE users.username = ?
`,
)
.get(identifier);
if (user == null) return null;
const keys = await ctx.getActorKeyPairs(identifier);
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: identifier,
name: user.name,
inbox: ctx.getInboxUri(identifier),
endpoints: new Endpoints({
sharedInbox: ctx.getInboxUri(),
}),
url: ctx.getActorUri(identifier),
publicKey: keys[0].cryptographicKey,
assertionMethods: keys.map((k) => k.multikey),
});
})
.setKeyPairsDispatcher(async (ctx, identifier) => {
const user = db
.prepare("SELECT * FROM users WHERE username = ?")
.get(identifier);
if (user == null) return [];
const rows = db
.prepare("SELECT * FROM keys WHERE keys.user_id = ?")
.all(user.id);
const keys = Object.fromEntries(
rows.map((row) => [row.type, row]),
) as Record;
const pairs: CryptoKeyPair[] = [];
// ユーザーがサポートする2つの鍵形式(RSASSA-PKCS1-v1_5およびEd25519)それぞれについて
// 鍵ペアを保有しているか確認し、なければ生成後データベースに保存:
for (const keyType of ["RSASSA-PKCS1-v1_5", "Ed25519"] as const) {
if (keys[keyType] == null) {
logger.debug(
"ユーザー{identifier}は{keyType}鍵を持っていません。作成します...",
{ identifier, keyType },
);
const { privateKey, publicKey } = await generateCryptoKeyPair(keyType);
db.prepare(
`
INSERT INTO keys (user_id, type, private_key, public_key)
VALUES (?, ?, ?, ?)
`,
).run(
user.id,
keyType,
JSON.stringify(await exportJwk(privateKey)),
JSON.stringify(await exportJwk(publicKey)),
);
pairs.push({ privateKey, publicKey });
} else {
pairs.push({
privateKey: await importJwk(
JSON.parse(keys[keyType].private_key),
"private",
),
publicKey: await importJwk(
JSON.parse(keys[keyType].public_key),
"public",
),
});
}
}
return pairs;
});
----
まず最初に注目すべきは、``setActorDispatcher()``メソッドに連続して呼び出されている``setKeyPairsDispatcher()``メソッドです。このメソッドは、コールバック関数から返された鍵ペアをアカウントに紐付ける役割を果たします。このように鍵ペアを紐付けることで、Fedifyがアクティビティを送信する際に自動的に登録された秘密鍵でデジタル署名を追加します。
``generateCryptoKeyPair()``関数は新しい秘密鍵と公開鍵のペアを生成し、``CryptoKeyPair``オブジェクトとして返します。参考までに、``CryptoKeyPair``型は``{ privateKey: CryptoKey; publicKey: CryptoKey; }``形式です。
``exportJwk()``関数は``CryptoKey``オブジェクトをJWK形式で表現したオブジェクトを返します。JWK形式が何かを知る必要はありません。単に暗号鍵をJSONで表現する標準的な形式だと理解すれば十分です。``CryptoKey``は暗号鍵をJavaScriptオブジェクトとして表現するためのウェブ標準の型です。
``importJwk()``関数はJWK形式で表現された鍵を``CryptoKey``オブジェクトに変換します。``exportJwk()``関数の逆だと理解すれば良いでしょう。
さて、では再び``setActorDispatcher()``メソッドに目を向けましょう。``getActorKeyPairs()``というメソッドが使われていますが、このメソッドは名前の通りアクターの鍵ペアを返します。アクターの鍵ペアは、直前に見た``setKeyPairsDispatcher()``メソッドで読み込まれたまさにその鍵ペアです。我々はRSA-PKCS#1-v1.5とEd25519形式の2つの鍵ペアを読み込んだので、``getActorKeyPairs()``メソッドは2つの鍵ペアの配列を返します。配列の各要素は鍵ペアを様々な形式で表現したオブジェクトですが、次のような形をしています:
[source,typescript]
----
interface ActorKeyPair {
privateKey: CryptoKey; // <1>
publicKey: CryptoKey; // <2>
keyId: URL; // <3>
cryptographicKey: CryptographicKey; // <4>
multikey: Multikey; // <5>
}
----
<1> 秘密鍵
<2> 公開鍵
<3> 鍵の一意な識別URI
<4> 公開鍵の別の形式
<5> 公開鍵のさらに別の形式
``CryptoKey``と``CryptographicKey``と``Multikey``がそれぞれどう違うのか、なぜこのように複数の形式が必要なのかは、ここで説明するには複雑すぎます。ただ、現時点では``Person``オブジェクトを初期化する際に``publicKey``属性は``CryptographicKey``形式を受け取り、``assertionMethods``属性は``Multikey[]``(``Multikey``の配列をTypeScriptでこのように表記)形式を受け取るということだけ覚えておきましょう。
ところで、``Person``オブジェクトには公開鍵を持つ属性が``publicKey``と``assertionMethods``の2つもあるのはなぜでしょうか?ActivityPubには元々``publicKey``属性しかありませんでしたが、後から複数の鍵を登録できるように``assertionMethods``属性が追加されました。先ほどRSA-PKCS#1-v1.5形式とEd25519形式の鍵を両方生成したのと同じような理由で、様々なソフトウェアとの互換性のために両方の属性を設定しているのです。よく見ると、レガシーな属性である``publicKey``にはレガシーな鍵形式であるRSA-PKCS#1-v1.5鍵のみを登録していることがわかります。(配列の最初の項目にRSA-PKCS#1-v1.5鍵ペアが、2番目の項目にEd25519鍵ペアが入ります)
[TIP]
====
実は``publicKey``属性も複数の鍵を含めることはできます。しかし、多くのソフトウェアが既に``publicKey``属性には単一の鍵しか入らないという前提で実装されているため、誤動作することが多いのです。これを避けるために``assertionMethods``という新しい属性が提案されたのです。
これに関して興味が湧いた方はlink:https://w3id.org/fep/521a[FEP-521a]文書を参照してください。
====
=== テスト
さて、アクターオブジェクトに暗号鍵を登録したので、うまく動作するか確認しましょう。次のコマンドでアクターを照会します。
[source,console]
fedify lookup http://localhost:8000/users/johndoe
正常に動作すれば、以下のような結果が出力されます:
[listing]
----
✔ Looking up the object...
Person {
id: URL "http://localhost:8000/users/johndoe",
name: "John Doe",
url: URL "http://localhost:8000/users/johndoe",
preferredUsername: "johndoe",
publicKey: CryptographicKey {
id: URL "http://localhost:8000/users/johndoe#main-key",
owner: URL "http://localhost:8000/users/johndoe",
publicKey: CryptoKey {
type: "public",
extractable: true,
algorithm: {
name: "RSASSA-PKCS1-v1_5",
modulusLength: 4096,
publicExponent: Uint8Array(3) [ 1, 0, 1 ],
hash: { name: "SHA-256" }
},
usages: [ "verify" ]
}
},
assertionMethods: [
Multikey {
id: URL "http://localhost:8000/users/johndoe#main-key",
controller: URL "http://localhost:8000/users/johndoe",
publicKey: CryptoKey {
type: "public",
extractable: true,
algorithm: {
name: "RSASSA-PKCS1-v1_5",
modulusLength: 4096,
publicExponent: Uint8Array(3) [ 1, 0, 1 ],
hash: { name: "SHA-256" }
},
usages: [ "verify" ]
}
},
Multikey {
id: URL "http://localhost:8000/users/johndoe#key-2",
controller: URL "http://localhost:8000/users/johndoe",
publicKey: CryptoKey {
type: "public",
extractable: true,
algorithm: { name: "Ed25519" },
usages: [ "verify" ]
}
}
],
inbox: URL "http://localhost:8000/users/johndoe/inbox",
endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" }
}
----
``Person``オブジェクトの``publicKey``属性にRSA-PKCS#1-v1.5形式の``CryptographicKey``オブジェクトが1つ、``assertionMethods``属性にRSA-PKCS#1-v1.5形式とEd25519形式の``Multikey``オブジェクトが2つ入っていることが確認できます。
== Mastodonとの連携
これで実際のMastodonから我々が作成したアクターを見ることができるか確認してみましょう。
=== 公開インターネットに露出
残念ながら、現在のサーバーはローカルでのみアクセス可能です。しかし、コードを修正するたびにどこかにデプロイしてテストするのは不便です。デプロイせずにすぐにローカルサーバーをインターネットに公開してテストできれば良いでしょう。
ここで、``fedify tunnel``がそのような場合に使用するコマンドです。ターミナルで新しいタブを開き、このコマンドの後にローカルサーバーのポート番号を入力します:
[source,console]
$ fedify tunnel 8000
そうすると、一度使って捨てるドメイン名を作成し、ローカルサーバーに中継します。外部からもアクセス可能なURLが出力されます:
[listing]
----
✔ Your local server at 8000 is now publicly accessible:
https://temp-address.serveo.net/
Press ^C to close the tunnel.
----
もちろん、皆さんには上記のURLとは異なる皆さん独自のユニークなURLが出力されているはずです。ウェブブラウザで(皆さんの固有の一時ドメインに置き換えてください)を開いて、きちんとアクセスできるか確認できます:
.公開インターネットに露出されたプロフィールページ
image::profile-page-2.png[公開インターネットに露出されたプロフィールページ]
上記のウェブページに表示されている皆さんのフェディバースハンドルをコピーした後、Mastodonに入って左上にある検索ボックスに貼り付けて検索してみてください:
.Mastodonでフェディバースハンドルで検索した結果
image::search-results.png[Mastodonでフェディバースハンドルで検索した結果]
上記のように検索結果に我々が作成したアクターが表示されれば正常です。検索結果でアクターの名前をクリックしてプロフィールページに入ることもできます:
.Mastodonで見るアクターのプロフィール
image::remote-profile.png[Mastodonで見るアクターのプロフィール]
しかし、ここまでです。まだフォローはできないので試さないでください!他のサーバーから我々が作成したアクターをフォローできるようにするには、インボックスを実装する必要があります。
NOTE: ``fedify tunnel``コマンドは、しばらく使わないと自動的に接続が切断されます。その場合は、kbd:[Ctrl+C]キーを押して終了させ、``fedify tunnel 8000``コマンドを再入力して新しい接続を結ぶ必要があります。
== インボックス
ActivityPubにおいて、インボックス(inbox)はアクターが他のアクターからアクティビティを受け取るエンドポイントです。すべてのアクターは自身のインボックスを持っており、これはHTTP ``POST``リクエストを通じてアクティビティを受け取ることができるURLです。他のアクターがフォローリクエストを送ったり、投稿を作成したり、コメントを追加したりなどの相互作用を行う際、該当するアクティビティは受信者のインボックスに届けられます。サーバーはインボックスに入ってきたアクティビティを処理し、適切に応答することで他のアクターと通信し、連合ネットワークの一部として機能するようになります。
インボックスは様々な種類のアクティビティを受信できますが、今はフォローリクエストを受け取ることから実装を始めましょう。
=== テーブルの作成
自分をフォローしているアクター(フォロワー)と自分がフォローしているアクター(フォロー中)を格納するために__src/schema.sql__ファイルに``follows``テーブルを定義します:
[source,sql]
----
CREATE TABLE IF NOT EXISTS follows (
following_id INTEGER REFERENCES actors (id),
follower_id INTEGER REFERENCES actors (id),
created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
CHECK (created <> ''),
PRIMARY KEY (following_id, follower_id)
);
----
今回も__src/schema.sql__を実行して``follows``テーブルを作成しましょう:
[source,console]
$ sqlite3 microblog.sqlite3 < src/schema.sql
__src/schema.ts__ファイルを開き、``follows``テーブルに保存されるレコードをJavaScriptで表現するための型も定義します:
[source,typescript]
----
export interface Follow {
following_id: number;
follower_id: number;
created: string;
}
----
=== ``Follow``アクティビティの受信
これでインボックスを実装する番です。実は、すでに__src/federation.ts__ファイルに次のようなコードを書いていました:
[source,typescript]
federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
上記のコードを修正する前に、Fedifyが提供する``Accept``および``Follow``クラスと``getActorHandle()``関数を``import``します:
[source,typescript,highlight=2,4,9]
----
import {
Accept,
Endpoints,
Follow,
Person,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
} from "@fedify/fedify";
----
そして``setInboxListeners()``メソッドを呼び出すコードを以下のように修正します:
[source,typescript]
----
federation
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
.on(Follow, async (ctx, follow) => {
if (follow.objectId == null) {
logger.debug("The Follow object does not have an object: {follow}", {
follow,
});
return;
}
const object = ctx.parseUri(follow.objectId);
if (object == null || object.type !== "actor") {
logger.debug("The Follow object's object is not an actor: {follow}", {
follow,
});
return;
}
const follower = await follow.getActor();
if (follower?.id == null || follower.inboxId == null) {
logger.debug("The Follow object does not have an actor: {follow}", {
follow,
});
return;
}
const followingId = db
.prepare(
`
SELECT * FROM actors
JOIN users ON users.id = actors.user_id
WHERE users.username = ?
`,
)
.get(object.identifier)?.id;
if (followingId == null) {
logger.debug(
"Failed to find the actor to follow in the database: {object}",
{ object },
);
}
const followerId = db
.prepare(
`
-- フォロワーアクターレコードを新規追加するか、既にあれば更新
INSERT INTO actors (uri, handle, name, inbox_url, shared_inbox_url, url)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (uri) DO UPDATE SET
handle = excluded.handle,
name = excluded.name,
inbox_url = excluded.inbox_url,
shared_inbox_url = excluded.shared_inbox_url,
url = excluded.url
WHERE
actors.uri = excluded.uri
RETURNING *
`,
)
.get(
follower.id.href,
await getActorHandle(follower),
follower.name?.toString(),
follower.inboxId.href,
follower.endpoints?.sharedInbox?.href,
follower.url?.href,
)?.id;
db.prepare(
"INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
).run(followingId, followerId);
const accept = new Accept({
actor: follow.objectId,
to: follow.actorId,
object: follow,
});
await ctx.sendActivity(object, follower, accept);
});
----
さて、コードをじっくり見てみましょう。``on()``メソッドは特定の種類のアクティビティが受信された時に取るべき行動を定義します。ここでは、フォローリクエストを意味する``Follow``アクティビティが受信された時にデータベースにフォロワー情報を記録した後、フォローリクエストを送ったアクターに対して承諾を意味する``Accept(Follow)``アクティビティを返信として送るコードを作成しました。
``follow.objectId``にはフォロー対象のアクターのURIが入っているはずです。``parseUri()``メソッドを通じて、この中に入っているURIが我々が作成したアクターを指しているかを確認します。
``getActorHandle()``関数は与えられたアクターオブジェクトからフェディバースハンドルを取得して文字列を返します。
フォローリクエストを送ったアクターに関する情報が``actors``テーブルにまだない場合は、まずレコードを追加します。すでにレコードがある場合は最新のデータで更新します。その後、``follows``テーブルにフォロワーを追加します。
データベースへの記録が完了すると、``sendActivity()``メソッドを使ってアクティビティを送ったアクターに``Accept(Follow)``アクティビティを返信として送ります。第一パラメータに送信者、第二パラメータに受信者、第三パラメータに送信するアクティビティオブジェクトを受け取ります。
=== ActivityPub.Academy
さて、それではフォローリクエストが正しく受信されるか確認しましょう。
通常のMastodonサーバーでテストしても問題ありませんが、アクティビティがどのように行き来するか具体的に確認できるlink:https://activitypub.academy/[ActivityPub.Academy]サーバーを利用することにします。ActivityPub.Academyは教育およびデバッグ目的の特殊なMastodonサーバーで、クリック一つで簡単に一時的なアカウントを作成できます。
.ActivityPub.Academyの最初のページ
image::academy.jpg[ActivityPub.Academyの最初のページ]
プライバシーポリシーに同意した後、btn:[登録する]ボタンを押して新しいアカウントを作成します。作成されたアカウントはランダムに生成された名前とハンドルを持ち、一日が経過すると自動的に消えます。代わりに、アカウントは何度でも新しく作成できます。
ログインが完了したら、画面の左上にある検索ボックスに我々が作成したアクターのハンドルを貼り付けて検索します:
.ActivityPub.Academyで我々が作成したアクターのハンドルで検索した結果
image::academy-search-results.png[ActivityPub.Academyで我々が作成したアクターのハンドルで検索した結果]
我々が作成したアクターが検索結果に表示されたら、右側にあるフォローボタンを押してフォローリクエストを送ります。そして右側のメニューからbtn:[Activity Log]をクリックします:
.ActivityPub.AcademyのActivity Log
image::activity-log.png[ActivityPub.AcademyのActivity Log]
すると、先ほどフォローボタンを押したことでActivityPub.Academyサーバーから我々が作成したアクターのインボックスに``Follow``アクティビティが送信されたという表示が見えます。右下のbtn:[show source]をクリックするとアクティビティの内容まで見ることができます:
.Activity Logでbtn:[show source]を押した画面
image::activity-log-2.png[Activity Logでshow sourceを押した画面]
=== テスト
アクティビティがきちんと送信されたことを確認したので、実際に我々が書いたインボックスコードがうまく動作したか確認する番です。まず``follows``テーブルにレコードがきちんと作成されたか見てみましょう:
[source,console]
$ echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3
フォローリクエストがきちんと処理されていれば、次のような結果が出力されるはずです(もちろん、時刻は異なるでしょう):
[cols="1,1,1"]
|===
| `following_id` | `follower_id` | `created`
| `1`
| `2`
| `2024-09-01 10:19:41`
|===
果たして``actors``テーブルにも新しいレコードができたか確認してみましょう:
[source,console]
$ echo "SELECT * FROM actors WHERE id > 1;" | sqlite3 -table microblog.sqlite3
[cols="1,1,1,1,1,1,1,1,1"]
|===
| `id` | `user_id` | `uri` | `handle` | `name` | `inbox_url` | `shared_inbox_url` | `url` | `created`
|`2`
|
|`https://activitypub.academy/users/dobussia_dovornath`
|`@dobussia_dovornath@activitypub.academy`
|`Dobussia Dovornath`
|`https://activitypub.academy/users/dobussia_dovornath/inbox`
|`https://activitypub.academy/inbox`
|`https://activitypub.academy/@dobussia_dovornath`
|`2024-09-01 10:19:41`
|===
再び、ActivityPub.AcademyのActivity Logを見てみましょう。我々が作成したアクターから送られた``Accept(Follow)``アクティビティがきちんと到着していれば、以下のように表示されるはずです:
.Activity Logに表示された``Accept(Follow)``アクティビティ
image::activity-log-3.png[Activity Logに表示されたAccept(Follow)アクティビティ]
さて、これで皆さんは初めてActivityPubを通じた相互作用を実装しました!
== フォロー解除
他のサーバーのアクターが我々が作成したアクターをフォローした後、再び解除するとどうなるでしょうか?link:https://activitypub.academy/[ActivityPub.Academy]で試してみましょう。先ほどと同様に、ActivityPub.Academyの検索ボックスに我々が作成したアクターのフェディバースハンドルを入力して検索します:
.ActivityPub.Academyの検索結果
image::academy-search-results-2.png[ActivityPub.Academyの検索結果]
よく見ると、アクター名の右側にあったフォローボタンの場所にフォロー解除(unfollow)ボタンがあります。このボタンを押してフォローを解除した後、Activity Logに入ってどのようなアクティビティが送信されるか確認してみましょう:
.送信された``Undo(Follow)``アクティビティが表示されているActivity Log
image::activity-log-4.png[送信されたUndo(Follow)アクティビティが表示されているActivity Log]
上のように``Undo(Follow)``アクティビティが送信されました。右下のbtn:[show source]を押すとアクティビティの詳細な内容を見ることができます:
[source,json]
----
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://activitypub.academy/users/dobussia_dovornath#follows/3283/undo",
"type": "Undo",
"actor": "https://activitypub.academy/users/dobussia_dovornath",
"object": {
"id": "https://activitypub.academy/98b131b8-89ea-49ba-b2bd-3ee0f5a87694",
"type": "Follow",
"actor": "https://activitypub.academy/users/dobussia_dovornath",
"object": "https://temp-address.serveo.net/users/johndoe"
}
}
----
上のJSONオブジェクトを見ると、``Undo(Follow)``アクティビティの中に先ほどインボックスに入ってきた``Follow``アクティビティが含まれていることがわかります。しかし、インボックスで``Undo(Follow)``アクティビティを受信した時の動作を何も定義していないため、何も起こりませんでした。
=== ``Undo(Follow)``アクティビティの受信
フォロー解除を実装するために__src/federation.ts__ファイルを開き、Fedifyが提供する``Undo``クラスを``import``します:
[source,typescript,highlight=6]
----
import {
Accept,
Endpoints,
Follow,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
} from "@fedify/fedify";
----
そして``on(Follow, ...)``の後に続けて``on(Undo, ...)``を追加します:
[source,typescript,highlight=6..23]
----
federation
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
.on(Follow, async (ctx, follow) => {
// ... 省略 ...
})
.on(Undo, async (ctx, undo) => {
const object = await undo.getObject();
if (!(object instanceof Follow)) return;
if (undo.actorId == null || object.objectId == null) return;
const parsed = ctx.parseUri(object.objectId);
if (parsed == null || parsed.type !== "actor") return;
db.prepare(
`
DELETE FROM follows
WHERE following_id = (
SELECT actors.id
FROM actors
JOIN users ON actors.user_id = users.id
WHERE users.username = ?
) AND follower_id = (SELECT id FROM actors WHERE uri = ?)
`,
).run(parsed.identifier, undo.actorId.href);
});
----
今回はフォローリクエストを処理する時よりもコードが短くなっています。``Undo(Follow)``アクティビティの中に入っているのが``Follow``アクティビティかどうか確認した後、``parseUri()``メソッドを使って取り消そうとしている``Follow``アクティビティのフォロー対象が我々が作成したアクターかどうか確認し、``follows``テーブルから該当するレコードを削除します。
=== テスト
先ほどlink:https://activitypub.academy/[ActivityPub.Academy]でフォロー解除ボタンを押してしまったので、もう一度フォロー解除をすることはできません。仕方がないので再度フォローした後、フォロー解除してテストする必要があります。しかしその前に、``follows``テーブルを空にする必要があります。そうしないと、フォローリクエストが来た時に既にレコードが存在するためエラーが発生してしまいます。
``sqlite3``コマンドを使用して``follows``テーブルを空にしましょう:
[source,console]
$ echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3
そして再度フォローボタンを押した後、データベースを確認します:
[source,console]
$ echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3
フォローリクエストがきちんと処理されていれば、次のような結果が出力されるはずです:
[cols="1,1,1"]
|===
| `following_id` | `follower_id` | `created`
| `1`
| `2`
| `2024-09-02 01:05:17`
|===
そして再度フォロー解除ボタンを押した後、データベースをもう一度確認します:
[source,console]
$ echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3
フォロー解除リクエストがきちんと処理されていれば、レコードが消えているので次のような結果が出力されるはずです:
[cols="1"]
|===
| `count(*)`
| `0`
|===
== フォロワーリスト
毎回フォロワーリストを``sqlite3``コマンドで見るのは面倒なので、ウェブでフォロワーリストを見られるようにしましょう。
まず__src/views.tsx__ファイルに新しいコンポーネントを追加することから始めます。``Actor``型を``import``してください:
[source,typescript]
import type { Actor } from "./schema.ts";
そして````コンポーネントと````コンポーネントを定義します:
[source,tsx]
----
export interface FollowerListProps {
followers: Actor[];
}
export const FollowerList: FC = ({ followers }) => (
<>
フォロワー
{followers.map((follower) => (
-
))}
>
);
export interface ActorLinkProps {
actor: Actor;
}
export const ActorLink: FC = ({ actor }) => {
const href = actor.url ?? actor.uri;
return actor.name == null ? (
{actor.handle}
) : (
<>
{actor.name}{" "}
(
{actor.handle}
)
>
);
};
----
````コンポーネントは1つのアクターを表現するのに使用され、````コンポーネントは````コンポーネントを使用してフォロワーリストを表現するのに使用されます。ご覧の通り、JSXには条件文や繰り返し文がないため、三項演算子と``Array.map()``メソッドを使用しています。
それではフォロワーリストを表示するエンドポイントを作成しましょう。__src/app.tsx__ファイルを開いて````コンポーネントを``import``します:
[source,typescript]
import { FollowerList, Layout, Profile, SetupForm } from "./views.tsx";
そして``GET /users/{username}/followers``に対するリクエストハンドラを追加します:
[source,tsx]
----
app.get("/users/:username/followers", async (c) => {
const followers = db
.prepare(
`
SELECT followers.*
FROM follows
JOIN actors AS followers ON follows.follower_id = followers.id
JOIN actors AS following ON follows.following_id = following.id
JOIN users ON users.id = following.user_id
WHERE users.username = ?
ORDER BY follows.created DESC
`,
)
.all(c.req.param("username"));
return c.html(
,
);
});
----
それでは、うまく表示されるか確認してみましょう。フォロワーがいるはずなので、``fedify tunnel``を起動した状態で他のMastodonサーバーやlink:https://activitypub.academy/[ActivityPub.Academy]から我々が作成したアクターをフォローしましょう。フォローリクエストが承認された後、ウェブブラウザでページを開くと、以下のように表示されるはずです:
.フォロワーリストページ
image::followers-list.png[フォロワーリストページ]
フォロワーリストを作成したので、プロフィールページでフォロワー数も表示すると良いでしょう。__src/views.tsx__ファイルを再度開き、````コンポーネントを以下のように修正します:
[source,tsx,highlight=3,5,10,12,20..23]
----
export interface ProfileProps {
name: string;
username: string;
handle: string;
followers: number;
}
export const Profile: FC = ({
name,
username,
handle,
followers,
}) => (
<>
{name}
{handle} ·{" "}
{followers === 1 ? "1 follower" : `${followers} followers`}
>
);
----
``ProfileProps``には2つのプロップが追加されました。``followers``は文字通りフォロワー数を含むプロップです。``username``はフォロワーリストへのリンクを張るためにURLに入れるユーザー名を受け取ります。
それでは再び__src/app.tsx__ファイルに戻り、``GET /users/{username}``リクエストハンドラを次のように修正します:
[source,tsx,highlight=5..15,21,23]
----
app.get("/users/:username", async (c) => {
// ... 省略 ...
if (user == null) return c.notFound();
// biome-ignore lint/style/noNonNullAssertion: 常に1つのレコードを返す
const { followers } = db
.prepare(
`
SELECT count(*) AS followers
FROM follows
JOIN actors ON follows.following_id = actors.id
WHERE actors.user_id = ?
`,
)
.get(user.id)!;
// ... 省略 ...
return c.html(
,
);
});
----
データベース内の``follows``テーブルのレコード数を数えるSQLが追加されました。さて、それでは変更されたプロフィールページを確認してみましょう。ウェブブラウザでページを開くと以下のように表示されるはずです:
.変更されたプロフィールページ
image::profile-page-3.png[変更されたプロフィールページ]
== フォロワーコレクション
しかし、一つ問題があります。ActivityPub.Academy以外の他のMastodonサーバーから我々が作成したアクターを照会してみましょう。(照会方法はもうご存知ですよね?公開インターネットに露出された状態で、アクターのハンドルをMastodonの検索ボックスに入力すれば良いのです)Mastodonで我々が作成したアクターのプロフィールを見ると、おそらく奇妙な点に気づくでしょう:
.Mastodonで照会した我々が作成したアクターのプロフィール
image::remote-profile-2.png[Mastodonで照会した我々が作成したアクターのプロフィール]
フォロワー数が0と表示されているのです。これは、我々が作成したアクターがフォロワーリストをActivityPubを通じて公開していないためです。ActivityPubでフォロワーリストを公開するには、フォロワーコレクションを定義する必要があります。
__src/federation.ts__ファイルを開いて、Fedifyが提供する``Recipient``型を``import``します:
[source,typescript,highlight=12]
----
import {
Accept,
Endpoints,
Follow,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
type Recipient,
} from "@fedify/fedify";
----
そして下の方にフォロワーコレクションディスパッチャーを追加します:
[source,typescript]
----
federation
.setFollowersDispatcher(
"/users/{identifier}/followers",
(ctx, identifier, cursor) =