ヒント
|
本書はAsciiDocで組版されており、以下のGitHubリポジトリからソースコードとPDF本を入手できます: |
このチュートリアルに入る前に、フェディバースとActivityPubについて簡単に説明しましょう。
フェディバース(fediverse)は、“federation”(連合)と“universe”(宇宙)を組み合わせた造語で、相互にやり取りができる分散型のソーシャルネットワークの集合体を指します。従来の中央集権型のソーシャルメディアプラットフォーム(例:X、Facebook)とは異なり、フェディバースは多数の独立したサーバー(インスタンス)で構成されています。
フェディバースの主な特徴:
-
分散型:単一の企業や団体が管理するのではなく、世界中の個人や組織が運営する多数のサーバーで構成されています。
-
相互運用性:異なるサーバー上のユーザーが相互にコミュニケーションを取ることができます。
-
オープンソース:多くのフェディバースソフトウェアはオープンソースで、誰でも自由に使用・改変できます。
-
データの自己管理:ユーザーは自分のデータをより直接的に管理できます。
ActivityPubは、フェディバースを支える重要な技術標準の一つです。これは、ソーシャルネットワーキングプロトコルであり、異なるサーバー間でのアクティビティ(投稿、コメント、いいね等)の共有方法を定義しています。
ActivityPubの主な特徴:
-
W3C勧告:World Wide Web Consortium(W3C)によって標準化されています。
-
クライアント-サーバー通信:ユーザーのクライアントアプリとサーバー間の通信を定義します。
-
サーバー間通信:異なるサーバー間でのアクティビティの配信方法を規定します。
-
JSON-LD形式:データはJSON for Linked Data (JSON-LD)形式で表現されます。
ActivityPubを採用することで、異なるソフトウェア間での相互運用性が実現され、ユーザーは自分の選んだサービスを使いながら、他のサービスのユーザーとコミュニケーションを取ることができます。
Fedifyは、ActivityPubやその他の標準規格を利用した連合サーバーアプリを作る為のTypeScriptライブラリです。 連合サーバーアプリを作る際の複雑さやボイラプレートコードを排除し、ビジネスロジックやユーザーエクスペリエンスに集中できる様にすることを目的としています。
Fedifyプロジェクトについてもっとお知りになりたい方は、以下の資料をご覧ください:
ご質問、ご提案、フィードバックなどございましたら、お気軽にGitHub Discussionsにご参加いただくか、フェディバースの@[email protected](日本語対応)までご連絡ください!
このチュートリアルは、Fedifyを学んでActivityPubサーバーソフトウェアを作ってみたい方を対象としています。
HTMLやHTTPを使用してウェブアプリケーションを作成した経験があり、コマンドラインインターフェース、SQL、JSON、基本的なJavaScriptなどを理解していることを前提としています。 ただし、TypeScriptやJSX、ActivityPub、Fedifyについては、このチュートリアルで必要な範囲で説明しますので、知らなくても大丈夫です。
ActivityPubソフトウェアを作成した経験は必要ありませんが、MastodonやMisskeyのようなActivityPubソフトウェアを少なくとも1つは使用したことがあることを想定しています。 そうすることで、私たちが何を作ろうとしているのかをイメージしやすくなります。
このチュートリアルでは、Fedifyを使用してActivityPubを通じて他の連合ソフトウェアやサービスと通信可能な個人用マイクロブログを作成します。このソフトウェアには以下の機能が含まれます:
-
ユーザーは1つのアカウントのみ作成できます。
-
フェディバース内の他のアカウントがユーザーをフォローできます。
-
フォロワーはユーザーのフォローを開始したり、やめたりできます。
-
ユーザーは自分のフォロワーリストを閲覧できます。
-
ユーザーは投稿を作成できます。
-
ユーザーの投稿はフェディバース内のフォロワーに表示されます。
-
ユーザーはフェディバース内の他のアカウントをフォローできます。
-
ユーザーは自分がフォローしているアカウントのリストを閲覧できます。
-
ユーザーは自分がフォローしているアカウントが作成した投稿を時系列順のリストで閲覧できます。
チュートリアルを単純化するために、以下の機能制約を設けています:
-
アカウントプロフィール(自己紹介文、画像など)は設定できません。
-
一度作成したアカウントは削除できません。
-
一度投稿した内容は編集や削除ができません。
-
一度フォローした他のアカウントのフォローを解除することはできません。
-
いいね、共有、コメント機能はありません。
-
検索機能はありません。
-
認証や権限チェックなどのセキュリティ機能はありません。
もちろん、チュートリアルを最後まで進めた後で機能を追加することは自由です。それは良い練習になるでしょう。
完成したソースコードはGitHubリポジトリにアップロードされており、各実装段階に応じてコミットが分かれていますので、参考にしてください。
FedifyはJavaScriptランタイムとしてDeno、Bun、Node.jsの3つをサポートしています。その中でもNode.jsが最も広く使われているため、このチュートリアルではNode.jsを基準に説明を進めていきます。
ヒント
|
JavaScriptランタイムとは、JavaScriptコードを実行するプラットフォームのことです。ウェブブラウザもJavaScriptランタイムの一つであり、コマンドラインやサーバーではNode.jsなどが広く使われています。最近ではCloudflare Workersのようなクラウドエッジ機能もJavaScriptランタイムの一つとして注目を集めています。 |
Fedifyを使用するにはNode.js 20.0.0以上のバージョンが必要です。様々なインストール方法がありますので、自分に最適な方法でNode.jsをインストールしてください。
Node.jsがインストールされると、node
コマンドとnpm
コマンドが使えるようになります:
$ node --version
$ npm --version
Fedifyプロジェクトをセットアップするために、fedify
コマンドをシステムにインストールする必要があります。複数のインストール方法がありますが、npm
コマンドを使用するのが最も簡単です:
$ npm install -g @fedify/cli
インストールが完了したら、fedify
コマンドが使用可能かどうか確認しましょう。以下のコマンドでfedify
コマンドのバージョンを確認できます。
$ fedify --version
表示されたバージョン番号が1.0.0以上であることを確認してください。それより古いバージョンだと、このチュートリアルを正しく進めることができません。
新しいFedifyプロジェクトを開始するために、作業ディレクトリのパスを決めましょう。このチュートリアルではmicroblogと名付けることにします。fedify init
コマンドの後にディレクトリパスを指定して実行します(ディレクトリがまだ存在しなくても大丈夫です):
$ fedify init microblog
fedify init
コマンドを実行すると、以下のような質問プロンプトが表示されます。順番にNode.js › npm › Hono › In-memory › In-processを選択します:
___ _____ _ _ __ /'_') | ___|__ __| (_)/ _|_ _ .-^^^-/ / | |_ / _ \/ _` | | |_| | | | __/ / | _| __/ (_| | | _| |_| | <__.|_|-|_| |_| \___|\__,_|_|_| \__, | |___/ ? 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
注釈
|
Fedifyはフルスタックフレームワークではなく、ActivityPubサーバーの実装に特化したフレームワークです。したがって、他のウェブフレームワークと一緒に使用することを前提に設計されています。このチュートリアルでは、ウェブフレームワークとして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ファイルがあります。
生成されたソースコードは動作するデモです。まずはこの状態で正常に動作するか確認しましょう:
$ npm run dev
上記のコマンドを実行すると、Ctrl+Cキーを押すまでサーバーが実行されたままになります:
Server started at http://0.0.0.0:8000
サーバーが実行された状態で、新しいターミナルタブを開き、以下のコマンドを実行します:
$ fedify lookup http://localhost:8000/users/john
上記のコマンドは、ローカルで起動したActivityPubサーバーの1つのアクター(actor)を照会したものです。ActivityPubにおいて、アクターは様々なActivityPubサーバー間でアクセス可能なアカウントだと考えてください。
以下のような結果が出力されれば正常です:
✔ 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であることがわかります。
ヒント
|
$ curl -H"Accept: application/activity+json" http://localhost:8000/users/john ただし、上記のように照会すると、その結果は人間の目で確認しにくいJSON形式になります。システムに $ curl -H"Accept: application/activity+json" http://localhost:8000/users/john | jq . |
Visual Studio Codeが皆さんのお気に入りのエディタでない可能性もあります。しかし、このチュートリアルを進める間はVisual Studio Codeを使用することをお勧めします。なぜなら、TypeScriptを使用する必要があり、Visual Studio Codeは現存する最も便利で優れたTypeScript IDEだからです。また、生成されたプロジェクトセットアップにはすでにVisual Studio Codeの設定が整っているため、フォーマッターやリントなどと格闘する必要もありません。
注意
|
Visual Studioと混同しないようにしてください。Visual Studio CodeとVisual Studioはブランドを共有しているだけで、まったく異なるソフトウェアです。 |
Visual Studio Codeをインストールした後、ファイル › フォルダを開く…メニューをクリックして作業ディレクトリを読み込んでください。
右下に「このリポジトリ 用のおすすめ拡張機能 'Biome' 拡張機能 提供元: biomejs をインストールしますか?」と尋ねるウィンドウが表示された場合は、インストールボタンをクリックしてその拡張機能をインストールしてください。この拡張機能をインストールすると、TypeScriptコードを作成する際にインデントや空白など、コードスタイルと格闘する必要がなく、自動的にコードがフォーマットされます。
ヒント
|
熱心なEmacsまたはVimユーザーの場合、使い慣れたお気に入りのエディタを使用することを止めはしません。ただし、TypeScript LSPの設定は確認しておくことをお勧めします。TypeScript LSPの設定の有無により、生産性に大きな差が出るからです。 |
コードを修正する前に、簡単にTypeScriptについて触れておきましょう。すでにTypeScriptに慣れている方は、この章をスキップしても構いません。
TypeScriptはJavaScriptに静的型チェックを追加したものです。TypeScriptの文法はJavaScriptの文法とほぼ同じですが、変数や関数の文法に型を指定できるという大きな違いがあります。型指定は変数やパラメータの後にコロン(:
)をつけて表します。
例えば、次のコードはfoo
変数が文字列(string
)であることを示しています:
let foo: string;
上記のように宣言されたfoo
変数に文字列以外の型の値を代入しようとすると、Visual Studio Codeが実行する前に赤い下線を引いて型エラーを表示します:
foo = 123; // (1)
-
ts(2322): 型
number
を型string
に割り当てることはできません。
コーディング中に赤い下線が表示されたら、無視せずに対処してください。無視してプログラムを実行すると、その部分で実際にエラーが発生する可能性が高いです。
TypeScriptでコーディングをしていて最も頻繁に遭遇する型エラーは、null
の可能性があるエラーです。例えば、次のコードではbar
変数が文字列(string
)である可能性もあればnull
である可能性もある(string | null
)と示されています:
const bar: string | null = someFunction();
この変数の内容から最初の文字を取り出そうとして、次のようなコードを書くとどうなるでしょうか:
const firstChar = bar.charAt(0); // (1)
-
ts(18047):
bar
はnull
の可能性があります。
上記のように型エラーが発生します。bar
が場合によってはnull
である可能性があり、その場合にnull.charAt(0)
を呼び出すとエラーが発生する可能性があるため、コードを修正するよう指摘しています。このような場合、以下のようにnull
の場合の処理を追加する必要があります:
const firstChar = bar === null ? "" : bar.charAt(0);
このように、TypeScriptはコーディング時に気づかなかった場合の数を想起させ、バグを未然に防ぐのに役立ちます。
また、TypeScriptの副次的な利点の一つは、自動補完が機能することです。例えば、foo.
まで入力すると、文字列オブジェクトが持つメソッドのリストが表示され、その中から選択できます。これにより、一々ドキュメントを確認しなくても迅速にコーディングが可能になります。
このチュートリアルを進めながら、TypeScriptの魅力も一緒に感じていただければと思います。何より、FedifyはTypeScriptと一緒に使用したときに最も良い体験が得られるのです。
ヒント
|
TypeScriptをしっかりじっくり学びたい場合は、公式のTypeScriptハンドブック(英語)を読むことをお勧めします。全部読むのに約30分ほどかかります。 |
JSXはJavaScriptコード内にXMLまたはHTMLを挿入できるようにするJavaScriptの文法拡張です。TypeScriptでも使用でき、その場合はTSXと呼ぶこともあります。このチュートリアルでは、すべてのHTMLをJSX文法を通じてJavaScriptコード内に記述します。JSXにすでに慣れている方は、この章をスキップして構いません。
例えば、以下のコードは<div>
要素が最上位にあるHTMLツリーをhtml
変数に代入します:
const html = <div>
<p id="greet">こんにちは、<strong>JSX</strong>!</p>
</div>;
中括弧を使用してJavaScript式を挿入することも可能です(以下のコードは、もちろんgetName()
関数が存在すると仮定しています):
const html = <div title={"こんにちは、" + getName() + "!"}>
<p id="greet">こんにちは、<strong>{getName()}</strong>!</p>
</div>;
JSXの特徴の1つは、コンポーネント(component)と呼ばれる独自のタグを定義できることです。コンポーネントは普通のJavaScript関数として定義できます。例えば、以下のコードは<Container>
コンポーネントを定義して使用する方法を示しています(コンポーネント名は一般的にPascalCaseスタイルに従います):
import type { FC } from "hono/jsx";
function getName() {
return "JSX";
}
interface ContainerProps {
name: string;
}
const Container: FC<ContainerProps> = (props) => {
return <div title={"こんにちは、" + props.name + "!"}>{props.children}</div>;
};
const html = <Container name={getName()}>
<p id="greet">こんにちは、<strong>{getName()}</strong>!</p>
</Container>;
上記のコードでFC
は、我々が使用するウェブフレームワークであるHonoが提供するもので、コンポーネントの型を定義するのに役立ちます。FC
はジェネリック型(generic type)で、FC<ContainerProps>
のように山括弧内に入る型が型引数です。ここでは型引数としてプロップ(props)の形式を指定しています。プロップとは、コンポーネントに渡すパラメータのことを指します。上記のコードでは、<Container>
コンポーネントのプロップ形式としてContainerProps
インターフェースを宣言して使用しています。
注釈
|
ジェネリック型の型引数は複数になる場合があり、カンマで各引数を区切ります。例えば、 また、ジェネリック関数というものもあり、 型引数が1つの場合、型引数を囲む山括弧がXML/HTMLタグのように見えますが、JSXの機能とは無関係です。
|
プロップとして渡されるもののうち、children
は特に注目する必要があります。これはコンポーネントの子要素がchildren
プロップとして渡されるためです。結果として、上記のコードでhtml
変数には<div title="こんにちは、JSX!"><p id="greet">こんにちは、<strong>JSX</strong>!</p></div>
というHTMLツリーが代入されることになります。
ヒント
|
JSXはReactプロジェクトで発明され、広く使用され始めました。JSXについて詳しく知りたい場合は、ReactのドキュメントのJSXでマークアップを記述するおよびJSXに波括弧でJavaScriptを含めるセクションを読んでみてください。 |
さて、本格的な開発に取り掛かりましょう。
最初に作成するのはアカウント作成ページです。アカウントを作成しないと投稿もできず、他のアカウントをフォローすることもできませんからね。まずは見える部分から作り始めましょう。
まず、src/views.tsxファイルを作成します。そして、そのファイル内にJSXで<Layout>
コンポーネントを定義します:
import type { FC } from "hono/jsx";
export const Layout: FC = (props) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Microblog</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
/>
</head>
<body>
<main class="container">{props.children}</main>
</body>
</html>
);
デザインに多くの時間を費やさないために、Pico CSSというCSSフレームワークを使用することにします。
ヒント
|
変数やパラメータの型をTypeScriptの型チェッカーが推論できる場合、上記のprops のように型表記を省略しても問題ありません。このように型表記が省略されている場合でも、Visual Studio Codeで変数名にマウスカーソルを合わせると、その変数がどの型であるかを確認できます。
|
次に、同じファイル内でレイアウトの中に入る<SetupForm>
コンポーネントを定義します:
export const SetupForm: FC = () => (
<>
<h1>Set up your microblog</h1>
<form method="post" action="/setup">
<fieldset>
<label>
Username{" "}
<input
type="text"
name="username"
required
maxlength={50}
pattern="^[a-z0-9_\-]+$"
/>
</label>
</fieldset>
<input type="submit" value="Setup" />
</form>
</>
);
JSXでは最上位に1つの要素しか置けませんが、<SetupForm>
コンポーネントでは<h1>
と<form>
の2つの要素を最上位に置いています。そのため、これを1つの要素のようにまとめるために、空のタグの形の<>
と</>
で囲んでいます。これをフラグメント(fragment)と呼びます。
定義したコンポーネントを組み合わせて使用する番です。src/app.tsxファイルで、先ほど定義した2つのコンポーネントをimport
します:
import { Layout, SetupForm } from "./views.tsx";
そして、/setupページで先ほど作成したアカウント作成フォームを表示します:
app.get("/setup", (c) =>
c.html(
<Layout>
<SetupForm />
</Layout>,
),
);
さて、それではウェブブラウザでhttp://localhost:8000/setupページを開いてみましょう。以下のような画面が表示されれば正常です:
注釈
|
JSXを使用するには、ソースファイルの拡張子が.jsxまたは.tsxである必要があります。この章で編集した2つのファイルの拡張子がどちらも.tsxであることに注意してください。 |
さて、見える部分を実装したので、次は動作を実装する番です。アカウント情報を保存する場所が必要ですが、SQLiteを使用することにしましょう。SQLiteは小規模なアプリケーションに適したリレーショナルデータベースです。
まずはアカウント情報を格納するテーブルを定義しましょう。今後、すべてのテーブル定義はsrc/schema.sqlファイルに記述することにします。アカウント情報はusers
テーブルに格納します:
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
コマンドが必要ですが、SQLiteのウェブサイトからダウンロードするか、各プラットフォームのパッケージマネージャーでインストールできます。macOSの場合は、オペレーティングシステムに組み込まれているので、別途ダウンロードする必要はありません。直接ダウンロードする場合は、オペレーティングシステムに合ったsqlite-tools-*.zipファイルをダウンロードして解凍してください。パッケージマネージャーを使用する場合は、次のコマンドでインストールすることもできます:
$ sudo apt install sqlite3 # (1)
$ sudo dnf install sqlite # (2)
> choco install sqlite # (3)
> scoop install sqlite # (4)
> winget install SQLite.SQLite # (5)
-
DebianおよびUbuntu
-
FedoraおよびRHEL
-
Chocolatey
-
Scoop
-
Windows Package Manager
さて、sqlite3
コマンドの準備ができたら、これを使用してデータベースファイルを作成しましょう:
$ sqlite3 microblog.sqlite3 < src/schema.sql
上記のコマンドを実行するとmicroblog.sqlite3ファイルが作成され、この中にSQLiteデータが保存されます。
これで、私たちが作成するアプリからSQLiteデータベースを使用するだけになりました。Node.jsでSQLiteデータベースを使用するには、SQLiteドライバーライブラリが必要です。ここではbetter-sqlite3パッケージを使用することにします。パッケージはnpm
コマンドで簡単にインストールできます:
$ npm add better-sqlite3
$ npm add --save-dev @types/better-sqlite3
ヒント
|
@types/better-sqlite3パッケージは、TypeScript用にbetter-sqlite3パッケージのAPIに関する型情報を含んでいます。このパッケージをインストールすることで、Visual Studio Codeで編集する際に自動補完や型チェックが可能になります。 このように、@types/スコープ内にあるパッケージをDefinitely Typedパッケージと呼びます。あるライブラリがTypeScriptで書かれていない場合、コミュニティが型情報を追加して作成したパッケージです。 |
パッケージをインストールしたので、このパッケージを使用してデータベースに接続するコードを書きましょう。src/db.tsという新しいファイルを作成し、以下のようにコーディングします:
import Database from "better-sqlite3";
const db = new Database("microblog.sqlite3");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
export default db;
ヒント
|
参考までに、
|
そして、users
テーブルに保存されるレコードをJavaScriptで表現する型を宣言しましょう。src/schema.tsファイルを作成し、以下のようにUser
型を定義します:
export interface User {
id: number;
username: string;
}
データベースに接続したので、レコードを挿入する番です。
まずsrc/app.tsxファイルを開き、レコード挿入に使用するdb
オブジェクトとUser
型をimport
します:
import db from "./db.ts";
import type { User } from "./schema.ts";
POST /setup
ハンドラを実装します:
app.post("/setup", async (c) => {
// アカウントが既に存在するか確認
const user = db.prepare<unknown[], User>("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
ハンドラにもアカウントが既に存在するかチェックするコードを追加します:
app.get("/setup", (c) => {
// アカウントが既に存在するか確認
const user = db.prepare<unknown[], User>("SELECT * FROM users LIMIT 1").get();
if (user != null) return c.redirect("/");
return c.html(
<Layout>
<SetupForm />
</Layout>,
);
});
これでアカウント作成機能がひととおり実装されたので、実際に使ってみましょう。ウェブブラウザでhttp://localhost:8000/setupページを開いてアカウントを作成してください。このチュートリアルでは、これ以降、ユーザー名としてjohndoeを使用したと仮定します。作成できたら、SQLiteデータベースにレコードが正しく挿入されたか確認もしてみましょう:
$ echo "SELECT * FROM users;" | sqlite3 -table microblog.sqlite3
レコードが正しく挿入されていれば、以下のような出力が表示されるはずです(もちろん、johndoe
は皆さんが入力したユーザー名によって異なります):
id |
username |
---|---|
|
|
これでアカウントが作成されたので、アカウント情報を表示するプロフィールページを実装しましょう。表示する情報はほとんどありませんが。
今回も見える部分から作業を始めましょう。src/views.tsxファイルに<Profile>
コンポーネントを定義します:
export interface ProfileProps {
name: string;
handle: string;
}
export const Profile: FC<ProfileProps> = ({ name, handle }) => (
<>
<hgroup>
<h1>{name}</h1>
<p style="user-select: all;">{handle}</p>
</hgroup>
</>
);
そしてsrc/app.tsxファイルで定義したコンポーネントをimport
します:
import { Layout, Profile, SetupForm } from "./views.tsx";
そして<Profile>
コンポーネントを表示するGET /users/{username}
ハンドラを追加します:
app.get("/users/:username", async (c) => {
const user = db
.prepare<unknown[], User>("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(
<Layout>
<Profile name={user.username} handle={handle} />
</Layout>,
);
});
ここまでできたらテストをしてみましょう。ウェブブラウザでhttp://localhost:8000/users/johndoeページを開いてみてください(アカウント作成時にユーザー名をjohndoe
にした場合。そうでない場合はURLを変更する必要があります)。以下のような画面が表示されるはずです:
ヒント
|
フェディバースハンドル(fediverse handle)、略してハンドルとは、フェディバース内でアカウントを指す一意なアドレスのようなものです。例えば 技術的には、ハンドルはWebFingerと |
ActivityPubは、その名前が示すように、アクティビティ(activity)を送受信するプロトコルです。投稿、投稿の編集、投稿の削除、投稿へのいいね、コメントの追加、プロフィールの編集…ソーシャルメディアで起こるすべての出来事をアクティビティとして表現します。
そして、すべてのアクティビティはアクター(actor)からアクターへ送信されます。例えば、山田太郎が投稿を作成すると、「投稿作成」(Create(Note)
)アクティビティが山田太郎から山田太郎のフォロワーたちに送信されます。その投稿に佐藤花子がいいねをすると、「いいね」(Like
)アクティビティが佐藤花子から山田太郎に送信されます。
したがって、ActivityPubを実装する最初のステップはアクターを実装することです。
fedify init
コマンドで生成されたデモアプリには既にとてもシンプルなアクターが実装されていますが、MastodonやMisskeyなどの実際のソフトウェアと通信するためには、アクターをもう少しきちんと実装する必要があります。
まずは、現在の実装を一度見てみましょう。src/federation.tsファイルを開いてみましょう:
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を追加してください:
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 <> '')
);
-
user_id
カラムはusers
カラムと連携するための外部キーです。該当レコードがリモートアクターを表す場合はNULL
が入りますが、現在のインスタンスサーバーのアカウントの場合は該当アカウントのusers.id
値が入ります。 -
uri
カラムはアクターIDと呼ばれるアクターの一意なURIを含みます。アクターを含むすべてのActivityPubオブジェクトはURI形式の一意なIDを持ちます。したがって、空にすることはできず、重複もできません。 -
handle
カラムは@[email protected]
形式のフェディバースハンドルを含みます。同様に、空にすることはできず、重複もできません。 -
name
カラムはUIに表示される名前を含みます。通常はフルネームやニックネームが入ります。ただし、ActivityPub仕様に従い、このカラムは空になる可能性があります。 -
inbox_url
カラムは該当アクターのインボックス(inbox)URLを含みます。インボックスが何であるかについては後で詳しく説明しますが、現時点ではアクターに必須で存在しなければならないということだけ覚えておいてください。このカラムも空にすることはできず、重複もできません。 -
shared_inbox_url
カラムは該当アクターの共有インボックス(shared inbox)URLを含みます。これについても後で詳しく説明します。必須ではないため、空になる可能性があり、カラム名の通り他のアクターと同じ共有インボックスURLを共有することもできます。 -
url
カラムは該当アクターのプロフィールURLを含みます。プロフィールURLとは、ウェブブラウザで開いて見ることができるプロフィールページのURLを意味します。アクターのIDとプロフィールURLが同じ場合もありますが、サービスによって異なる場合もあるため、その場合にこのカラムにプロフィールURLを含めます。空になる可能性があります。 -
created
カラムはレコードが作成された時点を記録します。空にすることはできず、デフォルトで挿入時点の時刻が記録されます。
さて、これでsrc/schema.sqlファイルをmicroblog.sqlite3データベースファイルに適用しましょう:
$ sqlite3 microblog.sqlite3 < src/schema.sql
ヒント
|
先ほどusers テーブルを定義する際にCREATE TABLE IF NOT EXISTS 文を使用したため、何度実行しても問題ありません。
|
そして、actors
テーブルに保存されるレコードをJavaScriptで表現する型もsrc/schema.tsに定義します:
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
カラムに入れる名前も入力を受け付けるようにしましょう:
export const SetupForm: FC = () => (
<>
<h1>Set up your microblog</h1>
<form method="post" action="/setup">
<fieldset>
<label>
Username{" "}
<input
type="text"
name="username"
required
maxlength={50}
pattern="^[a-z0-9_\-]+$"
/>
</label>
<label>
Name <input type="text" name="name" required />
</label>
</fieldset>
<input type="submit" value="Setup" />
</form>
</>
);
先ほど定義したActor
型をsrc/app.tsxでimport
します:
import type { Actor, User } from "./schema.ts";
これで入力された名前をはじめ、必要な情報をactors
テーブルのレコードとして作成するコードをPOST /setup
ハンドラに追加します:
app.post("/setup", async (c) => {
// アカウントが既に存在するか確認
const user = db
.prepare<unknown[], User>(
`
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}
ハンドラにも適用します:
app.get("/setup", (c) => {
// アカウントが既に存在するか確認
const user = db
.prepare<unknown[], User>(
`
SELECT * FROM users
JOIN actors ON (users.id = actors.user_id)
LIMIT 1
`,
)
.get();
if (user != null) return c.redirect("/");
return c.html(
<Layout>
<SetupForm />
</Layout>,
);
});
app.get("/users/:username", async (c) => {
const user = db
.prepare<unknown[], User & Actor>(
`
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(
<Layout>
<Profile name={user.name ?? user.username} handle={handle} />
</Layout>,
);
});
ヒント
|
TypeScriptではA & B はA 型と同時にB 型であるオブジェクトを意味します。例えば、{ a: number } & { b: string } 型があるとすると、{ a: 123 } や{ b: "foo" } はこの型を満たしませんが、{ a: 123, b: "foo" } はこの型を満たします。
|
最後に、src/federation.tsファイルを開き、アクターディスパッチャーの下に次のコードを追加します:
federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
setInboxListeners()
メソッドは今のところ気にしないでください。これもまたインボックスについて説明する際に一緒に扱うことにします。ただ、アカウント作成コードで使用したgetInboxUri()
メソッドが正しく動作するためには上記のコードが必要だという点だけ指摘しておきます。
コードをすべて修正したら、ブラウザでhttp://localhost:8000/setupページを開いて再度アカウントを作成します:
actors
テーブルを作成してレコードも追加したので、再びsrc/federation.tsファイルを修正しましょう。まずdb
オブジェクトとEndpoints
およびActor
をimport
します:
import { Endpoints, Person, createFederation } from "@fedify/fedify";
import db from "./db.ts";
import type { Actor, User } from "./schema.ts";
必要なものをimport
したのでsetActorDispatcher()
メソッドを修正しましょう:
federation.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
const user = db
.prepare<unknown[], User & Actor>(
`
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を一致させることにします。
ヒント
|
目のいい方々は気づいたかもしれませんが、HonoとFedify両方で このようにリクエストの |
ヒント
|
FedifyではすべてのURIおよびURLをURL インスタンスで表現します。
|
それでは、アクターディスパッチャーをテストしてみましょう。
サーバーが起動している状態で、新しいターミナルタブを開いて以下のコマンドを入力します:
$ fedify lookup http://localhost:8000/users/alice
alice
というアカウントが存在しないため、先ほどとは異なり、今度は次のようなエラーが発生するはずです:
✔ Looking up the object... Failed to fetch the object. It may be a private object. Try with -a/--authorized-fetch.
ではjohndoe
アカウントも照会してみましょう:
fedify lookup http://localhost:8000/users/johndoe
今度は結果がきちんと出力されます:
✔ 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ではアクターがアクティビティを作成して送信しますが、このときアクティビティを本当にそのアクターが作成したことを証明するためにデジタル署名を行います。そのために、アクターはペアになった自身だけの秘密鍵(private key)と公開鍵(public key)のペアを作成して持っており、その公開鍵を他のアクターも見られるように公開します。アクターはアクティビティを受信する際に、送信者の公開鍵とアクティビティの署名を検証して、そのアクティビティが本当に送信者が生成したものかどうかを確認します。署名と署名の検証はFedifyが自動的に行いますが、鍵ペアを生成して保存するのは直接実装する必要があります。
注意
|
秘密鍵は、その名前が示すように署名を行う主体以外はアクセスできないようにする必要があります。一方、公開鍵はその用途自体が公開することなので、誰でもアクセスしても問題ありません。 |
秘密鍵と公開鍵のペアを保存するkeys
テーブルをsrc/schema.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種類の値のみが許可されていることがわかります。一つはRSA-PKCS#1-v1.5形式で、もう一つはEd25519形式です。(それぞれが何を意味するかは、このチュートリアルでは重要ではありません)主キーが(user_id, type)
にかかっているので、1ユーザーに対して最大二つの鍵ペアが存在できます。
ヒント
|
このチュートリアルで詳しく説明することはできませんが、2024年9月現在、ActivityPubネットワークはRSA-PKCS#1-v1.5形式からEd25519形式に移行中であると知っておくと良いでしょう。あるソフトウェアはRSA-PKCS#1-v1.5形式のみを受け入れ、あるソフトウェアはEd25519形式を受け入れます。したがって、両方と通信するためには、二つの鍵ペアが両方とも必要になるのです。 |
private_key
およびpublic_key
カラムは文字列を受け取れるようになっていますが、ここにはJSONデータを入れる予定です。秘密鍵と公開鍵をJSONでエンコードする方法については、後で順を追って説明します。
ではkeys
テーブルを作成しましょう:
$ sqlite3 microblog.sqlite3 < src/schema.sql
keys
テーブルに保存されるレコードをJavaScriptで表現するKey
型もsrc/schema.tsファイルに定義します:
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
しましょう:
import {
Endpoints,
Person,
createFederation,
exportJwk,
generateCryptoKeyPair,
importJwk,
} from "@fedify/fedify";
import type { Actor, Key, User } from "./schema.ts";
そしてアクターディスパッチャー部分を次のように修正します:
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
const user = db
.prepare<unknown[], User & Actor>(
`
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<unknown[], User>("SELECT * FROM users WHERE username = ?")
.get(identifier);
if (user == null) return [];
const rows = db
.prepare<unknown[], Key>("SELECT * FROM keys WHERE keys.user_id = ?")
.all(user.id);
const keys = Object.fromEntries(
rows.map((row) => [row.type, row]),
) as Record<Key["type"], Key>;
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つの鍵ペアの配列を返します。配列の各要素は鍵ペアを様々な形式で表現したオブジェクトですが、次のような形をしています:
interface ActorKeyPair {
privateKey: CryptoKey; // (1)
publicKey: CryptoKey; // (2)
keyId: URL; // (3)
cryptographicKey: CryptographicKey; // (4)
multikey: Multikey; // (5)
}
-
秘密鍵
-
公開鍵
-
鍵の一意な識別URI
-
公開鍵の別の形式
-
公開鍵のさらに別の形式
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鍵ペアが入ります)
ヒント
|
実は これに関して興味が湧いた方はFEP-521a文書を参照してください。 |
さて、アクターオブジェクトに暗号鍵を登録したので、うまく動作するか確認しましょう。次のコマンドでアクターを照会します。
fedify lookup http://localhost:8000/users/johndoe
正常に動作すれば、以下のような結果が出力されます:
✔ 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から我々が作成したアクターを見ることができるか確認してみましょう。
残念ながら、現在のサーバーはローカルでのみアクセス可能です。しかし、コードを修正するたびにどこかにデプロイしてテストするのは不便です。デプロイせずにすぐにローカルサーバーをインターネットに公開してテストできれば良いでしょう。
ここで、fedify tunnel
がそのような場合に使用するコマンドです。ターミナルで新しいタブを開き、このコマンドの後にローカルサーバーのポート番号を入力します:
$ fedify tunnel 8000
そうすると、一度使って捨てるドメイン名を作成し、ローカルサーバーに中継します。外部からもアクセス可能なURLが出力されます:
✔ Your local server at 8000 is now publicly accessible: https://temp-address.serveo.net/ Press ^C to close the tunnel.
もちろん、皆さんには上記のURLとは異なる皆さん独自のユニークなURLが出力されているはずです。ウェブブラウザでhttps://temp-address.serveo.net/users/johndoe(皆さんの固有の一時ドメインに置き換えてください)を開いて、きちんとアクセスできるか確認できます:
上記のウェブページに表示されている皆さんのフェディバースハンドルをコピーした後、Mastodonに入って左上にある検索ボックスに貼り付けて検索してみてください:
上記のように検索結果に我々が作成したアクターが表示されれば正常です。検索結果でアクターの名前をクリックしてプロフィールページに入ることもできます:
しかし、ここまでです。まだフォローはできないので試さないでください!他のサーバーから我々が作成したアクターをフォローできるようにするには、インボックスを実装する必要があります。
注釈
|
fedify tunnel コマンドは、しばらく使わないと自動的に接続が切断されます。その場合は、Ctrl+Cキーを押して終了させ、fedify tunnel 8000 コマンドを再入力して新しい接続を結ぶ必要があります。
|
ActivityPubにおいて、インボックス(inbox)はアクターが他のアクターからアクティビティを受け取るエンドポイントです。すべてのアクターは自身のインボックスを持っており、これはHTTP POST
リクエストを通じてアクティビティを受け取ることができるURLです。他のアクターがフォローリクエストを送ったり、投稿を作成したり、コメントを追加したりなどの相互作用を行う際、該当するアクティビティは受信者のインボックスに届けられます。サーバーはインボックスに入ってきたアクティビティを処理し、適切に応答することで他のアクターと通信し、連合ネットワークの一部として機能するようになります。
インボックスは様々な種類のアクティビティを受信できますが、今はフォローリクエストを受け取ることから実装を始めましょう。
自分をフォローしているアクター(フォロワー)と自分がフォローしているアクター(フォロー中)を格納するためにsrc/schema.sqlファイルにfollows
テーブルを定義します:
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
テーブルを作成しましょう:
$ sqlite3 microblog.sqlite3 < src/schema.sql
src/schema.tsファイルを開き、follows
テーブルに保存されるレコードをJavaScriptで表現するための型も定義します:
export interface Follow {
following_id: number;
follower_id: number;
created: string;
}
これでインボックスを実装する番です。実は、すでにsrc/federation.tsファイルに次のようなコードを書いていました:
federation.setInboxListeners("/users/{identifier}/inbox", "/inbox");
上記のコードを修正する前に、Fedifyが提供するAccept
およびFollow
クラスとgetActorHandle()
関数をimport
します:
import {
Accept,
Endpoints,
Follow,
Person,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
} from "@fedify/fedify";
そしてsetInboxListeners()
メソッドを呼び出すコードを以下のように修正します:
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<unknown[], Actor>(
`
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<unknown[], Actor>(
`
-- フォロワーアクターレコードを新規追加するか、既にあれば更新
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)
アクティビティを返信として送ります。第一パラメータに送信者、第二パラメータに受信者、第三パラメータに送信するアクティビティオブジェクトを受け取ります。
さて、それではフォローリクエストが正しく受信されるか確認しましょう。
通常のMastodonサーバーでテストしても問題ありませんが、アクティビティがどのように行き来するか具体的に確認できるActivityPub.Academyサーバーを利用することにします。ActivityPub.Academyは教育およびデバッグ目的の特殊なMastodonサーバーで、クリック一つで簡単に一時的なアカウントを作成できます。
プライバシーポリシーに同意した後、登録するボタンを押して新しいアカウントを作成します。作成されたアカウントはランダムに生成された名前とハンドルを持ち、一日が経過すると自動的に消えます。代わりに、アカウントは何度でも新しく作成できます。
ログインが完了したら、画面の左上にある検索ボックスに我々が作成したアクターのハンドルを貼り付けて検索します:
我々が作成したアクターが検索結果に表示されたら、右側にあるフォローボタンを押してフォローリクエストを送ります。そして右側のメニューからActivity Logをクリックします:
すると、先ほどフォローボタンを押したことでActivityPub.Academyサーバーから我々が作成したアクターのインボックスにFollow
アクティビティが送信されたという表示が見えます。右下のshow sourceをクリックするとアクティビティの内容まで見ることができます:
アクティビティがきちんと送信されたことを確認したので、実際に我々が書いたインボックスコードがうまく動作したか確認する番です。まずfollows
テーブルにレコードがきちんと作成されたか見てみましょう:
$ echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3
フォローリクエストがきちんと処理されていれば、次のような結果が出力されるはずです(もちろん、時刻は異なるでしょう):
following_id |
follower_id |
created |
---|---|---|
|
|
|
果たしてactors
テーブルにも新しいレコードができたか確認してみましょう:
$ echo "SELECT * FROM actors WHERE id > 1;" | sqlite3 -table microblog.sqlite3
id |
user_id |
uri |
handle |
name |
inbox_url |
shared_inbox_url |
url |
created |
---|---|---|---|---|---|---|---|---|
|
|
|
再び、ActivityPub.AcademyのActivity Logを見てみましょう。我々が作成したアクターから送られたAccept(Follow)
アクティビティがきちんと到着していれば、以下のように表示されるはずです:
さて、これで皆さんは初めてActivityPubを通じた相互作用を実装しました!
他のサーバーのアクターが我々が作成したアクターをフォローした後、再び解除するとどうなるでしょうか?ActivityPub.Academyで試してみましょう。先ほどと同様に、ActivityPub.Academyの検索ボックスに我々が作成したアクターのフェディバースハンドルを入力して検索します:
よく見ると、アクター名の右側にあったフォローボタンの場所にフォロー解除(unfollow)ボタンがあります。このボタンを押してフォローを解除した後、Activity Logに入ってどのようなアクティビティが送信されるか確認してみましょう:
上のようにUndo(Follow)
アクティビティが送信されました。右下のshow sourceを押すとアクティビティの詳細な内容を見ることができます:
{
"@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)
アクティビティを受信した時の動作を何も定義していないため、何も起こりませんでした。
フォロー解除を実装するためにsrc/federation.tsファイルを開き、Fedifyが提供するUndo
クラスをimport
します:
import {
Accept,
Endpoints,
Follow,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
} from "@fedify/fedify";
そしてon(Follow, …)
の後に続けてon(Undo, …)
を追加します:
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
テーブルから該当するレコードを削除します。
先ほどActivityPub.Academyでフォロー解除ボタンを押してしまったので、もう一度フォロー解除をすることはできません。仕方がないので再度フォローした後、フォロー解除してテストする必要があります。しかしその前に、follows
テーブルを空にする必要があります。そうしないと、フォローリクエストが来た時に既にレコードが存在するためエラーが発生してしまいます。
sqlite3
コマンドを使用してfollows
テーブルを空にしましょう:
$ echo "DELETE FROM follows;" | sqlite3 microblog.sqlite3
そして再度フォローボタンを押した後、データベースを確認します:
$ echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3
フォローリクエストがきちんと処理されていれば、次のような結果が出力されるはずです:
following_id |
follower_id |
created |
---|---|---|
|
|
|
そして再度フォロー解除ボタンを押した後、データベースをもう一度確認します:
$ echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3
フォロー解除リクエストがきちんと処理されていれば、レコードが消えているので次のような結果が出力されるはずです:
count(*) |
---|
|
毎回フォロワーリストをsqlite3
コマンドで見るのは面倒なので、ウェブでフォロワーリストを見られるようにしましょう。
まずsrc/views.tsxファイルに新しいコンポーネントを追加することから始めます。Actor
型をimport
してください:
import type { Actor } from "./schema.ts";
そして<FollowerList>
コンポーネントと<ActorLink>
コンポーネントを定義します:
export interface FollowerListProps {
followers: Actor[];
}
export const FollowerList: FC<FollowerListProps> = ({ followers }) => (
<>
<h2>フォロワー</h2>
<ul>
{followers.map((follower) => (
<li key={follower.id}>
<ActorLink actor={follower} />
</li>
))}
</ul>
</>
);
export interface ActorLinkProps {
actor: Actor;
}
export const ActorLink: FC<ActorLinkProps> = ({ actor }) => {
const href = actor.url ?? actor.uri;
return actor.name == null ? (
<a href={href} class="secondary">
{actor.handle}
</a>
) : (
<>
<a href={href}>{actor.name}</a>{" "}
<small>
(
<a href={href} class="secondary">
{actor.handle}
</a>
)
</small>
</>
);
};
<ActorLink>
コンポーネントは1つのアクターを表現するのに使用され、<FollowerList>
コンポーネントは<ActorList>
コンポーネントを使用してフォロワーリストを表現するのに使用されます。ご覧の通り、JSXには条件文や繰り返し文がないため、三項演算子とArray.map()
メソッドを使用しています。
それではフォロワーリストを表示するエンドポイントを作成しましょう。src/app.tsxファイルを開いて<FollowerList>
コンポーネントをimport
します:
import { FollowerList, Layout, Profile, SetupForm } from "./views.tsx";
そしてGET /users/{username}/followers
に対するリクエストハンドラを追加します:
app.get("/users/:username/followers", async (c) => {
const followers = db
.prepare<unknown[], Actor>(
`
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(
<Layout>
<FollowerList followers={followers} />
</Layout>,
);
});
それでは、うまく表示されるか確認してみましょう。フォロワーがいるはずなので、fedify tunnel
を起動した状態で他のMastodonサーバーやActivityPub.Academyから我々が作成したアクターをフォローしましょう。フォローリクエストが承認された後、ウェブブラウザでhttp://localhost:8000/users/johndoe/followersページを開くと、以下のように表示されるはずです:
フォロワーリストを作成したので、プロフィールページでフォロワー数も表示すると良いでしょう。src/views.tsxファイルを再度開き、<Profile>
コンポーネントを以下のように修正します:
export interface ProfileProps {
name: string;
username: string;
handle: string;
followers: number;
}
export const Profile: FC<ProfileProps> = ({
name,
username,
handle,
followers,
}) => (
<>
<hgroup>
<h1>
<a href={`/users/${username}`}>{name}</a>
</h1>
<p>
<span style="user-select: all;">{handle}</span> ·{" "}
<a href={`/users/${username}/followers`}>
{followers === 1 ? "1 follower" : `${followers} followers`}
</a>
</p>
</hgroup>
</>
);
ProfileProps
には2つのプロップが追加されました。followers
は文字通りフォロワー数を含むプロップです。username
はフォロワーリストへのリンクを張るためにURLに入れるユーザー名を受け取ります。
それでは再びsrc/app.tsxファイルに戻り、GET /users/{username}
リクエストハンドラを次のように修正します:
app.get("/users/:username", async (c) => {
// ... 省略 ...
if (user == null) return c.notFound();
// biome-ignore lint/style/noNonNullAssertion: 常に1つのレコードを返す
const { followers } = db
.prepare<unknown[], { followers: number }>(
`
SELECT count(*) AS followers
FROM follows
JOIN actors ON follows.following_id = actors.id
WHERE actors.user_id = ?
`,
)
.get(user.id)!;
// ... 省略 ...
return c.html(
<Layout>
<Profile
name={user.name ?? user.username}
username={user.username}
handle={handle}
followers={followers}
/>
</Layout>,
);
});
データベース内のfollows
テーブルのレコード数を数えるSQLが追加されました。さて、それでは変更されたプロフィールページを確認してみましょう。ウェブブラウザでhttp://localhost:8000/users/johndoeページを開くと以下のように表示されるはずです:
しかし、一つ問題があります。ActivityPub.Academy以外の他のMastodonサーバーから我々が作成したアクターを照会してみましょう。(照会方法はもうご存知ですよね?公開インターネットに露出された状態で、アクターのハンドルをMastodonの検索ボックスに入力すれば良いのです)Mastodonで我々が作成したアクターのプロフィールを見ると、おそらく奇妙な点に気づくでしょう:
フォロワー数が0と表示されているのです。これは、我々が作成したアクターがフォロワーリストをActivityPubを通じて公開していないためです。ActivityPubでフォロワーリストを公開するには、フォロワーコレクションを定義する必要があります。
src/federation.tsファイルを開いて、Fedifyが提供するRecipient
型をimport
します:
import {
Accept,
Endpoints,
Follow,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
type Recipient,
} from "@fedify/fedify";
そして下の方にフォロワーコレクションディスパッチャーを追加します:
federation
.setFollowersDispatcher(
"/users/{identifier}/followers",
(ctx, identifier, cursor) => {
const followers = db
.prepare<unknown[], Actor>(
`
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(identifier);
const items: Recipient[] = followers.map((f) => ({
id: new URL(f.uri),
inboxId: new URL(f.inbox_url),
endpoints:
f.shared_inbox_url == null
? null
: { sharedInbox: new URL(f.shared_inbox_url) },
}));
return { items };
},
)
.setCounter((ctx, identifier) => {
const result = db
.prepare<unknown[], { cnt: number }>(
`
SELECT count(*) AS cnt
FROM follows
JOIN actors ON actors.id = follows.following_id
JOIN users ON users.id = actors.user_id
WHERE users.username = ?
`,
)
.get(identifier);
return result == null ? 0 : result.cnt;
});
setFollowersDispatcher()
メソッドでは、GET /users/{identifier}/followers
リクエストが来た時に応答するフォロワーコレクションオブジェクトを作成します。SQLが少し長くなっていますが、整理するとidentifier
パラメータで入ってきたユーザー名をフォローしているアクターのリストを取得しているのです。items
にはRecipient
オブジェクトを含めますが、Recipient
型は次のような形をしています:
export interface Recipient {
readonly id: URL | null; // (1)
readonly inboxId: URL | null; // (2)
readonly endpoints?: {
sharedInbox: URL | null; // (3)
} | null;
}
-
id
属性にはアクターの一意なIRIが入り、 -
inboxId
にはアクターの個人インボックスURLが入ります。 -
endpoints.sharedInbox
にはアクターの共有インボックスURLが入ります。
我々はactors
テーブルにそれらの情報をすべて含んでいるので、その情報でitems
配列を埋めることができます。
setCounter()
メソッドではフォロワーコレクションの全体数量を求めます。ここでもSQLが少し複雑ですが、要約するとidentifier
パラメータで入ってきたユーザー名をフォローしているアクターの数を求めているのです。
それではフォロワーコレクションがうまく動作するか確認するために、fedify lookup
コマンドを使用しましょう:
$ fedify lookup http://localhost:8000/users/johndoe/followers
正しく実装されていれば以下のような結果が出るはずです:
✔ Looking up the object... OrderedCollection { totalItems: 1, items: [ URL "https://activitypub.academy/users/dobussia_dovornath" ] }
しかし、このようにフォロワーコレクションを作成しただけでは、他のサーバーがフォロワーコレクションがどこにあるのか知ることができません。そのため、アクターディスパッチャーでフォロワーコレクションにリンクを張る必要があります:
federation
.setActorDispatcher("/users/{identifier}", async (ctx, identifier) => {
// ... 省略 ...
return new Person({
// ... 省略 ...
followers: ctx.getFollowersUri(identifier),
});
})
アクターもfedify lookup
で照会してみましょう:
$ fedify lookup http://localhost:8000/users/johndoe
以下のように結果に"followers"
属性が含まれていれば成功です:
✔ Looking up the object... Person { ... 省略 ... inbox: URL "http://localhost:8000/users/johndoe/inbox", followers: URL "http://localhost:8000/users/johndoe/followers", endpoints: Endpoints { sharedInbox: URL "http://localhost:8000/inbox" } }
それでは再びMastodonで我々が作成したアクターを照会してみましょう。しかし、その結果は少し落胆させられるかもしれません:
フォロワー数は依然として0と表示されています。これは、Mastodonが他のサーバーのアクター情報をキャッシュ(cache)しているためです。これを更新する方法はありますが、F5キーを押すように簡単ではありません:
-
一つの方法は、一週間待つことです。Mastodonは他のサーバーのアクター情報を含むキャッシュを最後の更新から7日が経過すると削除するからです。
-
もう一つの方法は、
Update
アクティビティを送信することですが、これには面倒なコーディングが必要です。 -
あるいは、まだキャッシュされていない他のMastodonサーバーで照会してみるのも一つの方法でしょう。
-
最後の方法は、
fedify tunnel
を一度終了して再起動し、新しい一時ドメインを割り当てることです。
皆さんが他のMastodonサーバーで正確なフォロワー数が表示されるのを直接確認したい場合は、私が列挙した方法のいずれかを試してみてください。
さて、いよいよ投稿を実装する時が来ました。一般的なブログとは異なり、我々が作成するマイクロブログは他のサーバーで作成された投稿も保存できる必要があります。これを念頭に置いて設計しましょう。
まずposts
テーブルを作成しましょう。src/schema.sqlファイルを開いて以下のSQLを追加します:
CREATE TABLE IF NOT EXISTS posts (
id INTEGER NOT NULL PRIMARY KEY, -- (1)
uri TEXT NOT NULL UNIQUE CHECK (uri <> ''), -- (2)
actor_id INTEGER NOT NULL REFERENCES actors (id), -- (3)
content TEXT NOT NULL, -- (4)
url TEXT CHECK (url LIKE 'https://%' OR url LIKE 'http://%'), -- (5)
created TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) CHECK (created <> '') -- (6)
);
-
id
カラムはテーブルの主キーです。 -
uri
カラムは投稿の一意なURIを含みます。先ほど述べたように、ActivityPubオブジェクトはすべて一意なURIを持つ必要があるためです。 -
actor_id
カラムは投稿を作成したアクターを指します。 -
content
カラムには投稿の内容を含みます。 -
url
カラムにはウェブブラウザで投稿を表示するURLを含みます。ActivityPubオブジェクトのURIとウェブブラウザに表示されるページのURLが一致する場合もありますが、そうでない場合もあるため、別のカラムが必要です。ただし、空である可能性もあります。 -
created
カラムには投稿作成時刻を含みます。
SQLを実行してposts
テーブルを作成しましょう:
$ sqlite3 microblog.sqlite3 < src/schema.sql
posts
テーブルに保存されるレコードをJavaScriptで表現するPost
型もsrc/schema.tsファイルに定義します:
export interface Post {
id: number;
uri: string;
actor_id: number;
content: string;
url: string | null;
created: string;
}
投稿を作成するには、どこかにフォームが必要ですね。そういえば、まだトップページもきちんと作成していませんでした。トップページに投稿作成フォームを追加しましょう。
まずsrc/views.tsxファイルを開いてUser
型をimport
します:
import type { Actor, User } from "./schema.ts";
そして<Home>
コンポーネントを定義します:
export interface HomeProps {
user: User & Actor;
}
export const Home: FC<HomeProps> = ({ user }) => (
<>
<hgroup>
<h1>{user.name}'s microblog</h1>
<p>
<a href={`/users/${user.username}`}>{user.name}'s profile</a>
</p>
</hgroup>
<form method="post" action={`/users/${user.username}/posts`}>
<fieldset>
<label>
<textarea name="content" required={true} placeholder="What's up?" />
</label>
</fieldset>
<input type="submit" value="Post" />
</form>
</>
);
次にsrc/app.tsxファイルを開いて先ほど定義した<Home>
コンポーネントをimport
します:
import { FollowerList, Home, Layout, Profile, SetupForm } from "./views.tsx";
そして既にあるGET /
リクエストハンドラを:
app.get("/", (c) => c.text("Hello, Fedify!"));
以下のように修正します:
app.get("/", (c) => {
const user = db
.prepare<unknown[], User & Actor>(
`
SELECT users.*, actors.*
FROM users
JOIN actors ON users.id = actors.user_id
LIMIT 1
`,
)
.get();
if (user == null) return c.redirect("/setup");
return c.html(
<Layout>
<Home user={user} />
</Layout>,
);
});
ここまでできたら、トップページがうまく表示されるか確認しましょう。ウェブブラウザでhttp://localhost:8000/ページを開くと以下のように表示されるはずです:
投稿作成フォームを作成したので、実際に投稿内容をposts
テーブルに保存するコードが必要です。
まずsrc/federation.tsファイルを開いてFedifyが提供するNote
クラスをimport
します:
import {
Accept,
Endpoints,
Follow,
Note,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
type Recipient,
} from "@fedify/fedify";
以下のコードを追加します:
federation.setObjectDispatcher(
Note,
"/users/{identifier}/posts/{id}",
(ctx, values) => {
return null;
},
);
上記のコードはまだ特に役割を果たしませんが、投稿のパーマリンク形式を決めるのに必要です。実際の実装は後でしましょう。
ActivityPubでは投稿の内容をHTML形式でやり取りします。したがって、プレーンテキスト形式で入力された内容をHTML形式に変換する必要があります。その際、<
、>
などの文字をHTMLで表示できるように<
、>
などのHTMLエンティティに変換してくれるstringify-entitiesパッケージが必要です:
$ npm add stringify-entities
そしてsrc/app.tsxファイルを開いてインストールしたパッケージをimport
します。
import { stringifyEntities } from "stringify-entities";
Post
型とFedifyが提供するNote
クラスもimport
します:
import type { Actor, Post, User } from "./schema.ts";
import { Note } from "@fedify/fedify";
そしてPOST /users/{username}/posts
リクエストハンドラを実装します:
app.post("/users/:username/posts", async (c) => {
const username = c.req.param("username");
const actor = db
.prepare<unknown[], Actor>(
`
SELECT actors.*
FROM actors
JOIN users ON users.id = actors.user_id
WHERE users.username = ?
`,
)
.get(username);
if (actor == null) return c.redirect("/setup");
const form = await c.req.formData();
const content = form.get("content")?.toString();
if (content == null || content.trim() === "") {
return c.text("Content is required", 400);
}
const ctx = fedi.createContext(c.req.raw, undefined);
const url: string | null = db.transaction(() => {
const post = db
.prepare<unknown[], Post>(
`
INSERT INTO posts (uri, actor_id, content)
VALUES ('https://localhost/', ?, ?)
RETURNING *
`,
)
.get(actor.id, stringifyEntities(content, { escapeOnly: true }));
if (post == null) return null;
const url = ctx.getObjectUri(Note, {
handle: username,
id: post.id.toString(),
}).href;
db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
url,
url,
post.id,
);
return url;
})();
if (url == null) return c.text("Failed to create post", 500);
return c.redirect(url);
});
普通にposts
テーブルにレコードを追加するコードですが、一つ特殊な部分があります。投稿を表すActivityPubオブジェクトのURIを求めるにはposts.id
が先に決まっている必要があるため、posts.uri
カラムにhttps://localhost/
という仮のURIをまず入れてレコードを追加した後、決定したposts.id
を基にgetObjectUri()
メソッドを使用して実際のURIを求めてレコードを更新するようになっています。
それではウェブブラウザでhttp://localhost:8000/ページを開いた後、投稿を作成してみましょう:
Postボタンを押して投稿を作成すると、残念ながら404 Not Found
エラーが発生します:
というのも、投稿パーマリンクにリダイレクトするよう実装したのに、まだ投稿ページを実装していないからです。しかし、それでもposts
テーブルにはレコードが作成されているはずです。一度確認してみましょう:
$ echo "SELECT * FROM posts;" | sqlite3 -table microblog.sqlite3
すると次のような結果が出力されるはずです:
id |
uri |
actor_id |
content |
url |
created |
---|---|---|---|---|---|
|
|
|
|
投稿作成後に404 Not Found
エラーが発生しないよう、投稿ページを実装しましょう。
src/views.tsxファイルを開いてPost
型をimport
します:
import type { Actor, Post, User } from "./schema.ts";
そして<PostPage>
コンポーネントおよび<PostView>
コンポーネントを定義します:
export interface PostPageProps extends ProfileProps, PostViewProps {}
export const PostPage: FC<PostPageProps> = (props) => (
<>
<Profile
name={props.name}
username={props.username}
handle={props.handle}
followers={props.followers}
/>
<PostView post={props.post} />
</>
);
export interface PostViewProps {
post: Post & Actor;
}
export const PostView: FC<PostViewProps> = ({ post }) => (
<article>
<header>
<ActorLink actor={post} />
</header>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: */}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<footer>
<a href={post.url ?? post.uri}>
<time datetime={new Date(post.created).toISOString()}>
{post.created}
</time>
</a>
</footer>
</article>
);
これでデータベースから投稿データを読み込んで<PostPage>
コンポーネントでレンダリングしましょう。src/app.tsxファイルを開いて先ほど定義した<PostPage>
コンポーネントをimport
します:
import {
FollowerList,
Home,
Layout,
PostPage,
Profile,
SetupForm,
} from "./views.tsx";
そしてGET /users/{username}/posts/{id}
リクエストハンドラを実装します:
app.get("/users/:username/posts/:id", (c) => {
const post = db
.prepare<unknown[], Post & Actor & User>(
`
SELECT users.*, actors.*, posts.*
FROM posts
JOIN actors ON actors.id = posts.actor_id
JOIN users ON users.id = actors.user_id
WHERE users.username = ? AND posts.id = ?
`,
)
.get(c.req.param("username"), c.req.param("id"));
if (post == null) return c.notFound();
// biome-ignore lint/style/noNonNullAssertion: 常に1つのレコードを返す
const { followers } = db
.prepare<unknown[], { followers: number }>(
`
SELECT count(*) AS followers
FROM follows
WHERE follows.following_id = ?
`,
)
.get(post.actor_id)!;
return c.html(
<Layout>
<PostPage
name={post.name ?? post.username}
username={post.username}
handle={post.handle}
followers={followers}
post={post}
/>
</Layout>,
);
});
それでは先ほど404 Not Found
エラーが発生したhttp://localhost:8000/users/johndoe/posts/1ページをウェブブラウザで開いてみましょう:
それでは、他のMastodonのサーバーで投稿を照会できるか確認してみましょう。まず、fedify tunnel
を利用してローカルサーバーを公開インターネットに露出します。
その状態で、Mastodonの検索ボックスに記事のパーマリンクであるhttps://temp-address.serveo.net/users/johndoe/posts/1(皆さんの固有の一時ドメインに置き換えてください)を入力してみます:
残念ながら、検索結果は空です。投稿をActivityPubオブジェクトの形式で公開していないからです。では、投稿をActivityPubオブジェクトで露出させてみましょう。
実装に先立ち、必要なライブラリをインストールする必要があります。Fedifyで時の表現に使用するTemporal APIがまだNode.jsに組み込まれていないため、これをポリフィルする@js-temporal/polyfillパッケージが必要です:
$ npm add @js-temporal/polyfill
src/federation.tsファイルを開き、インストールしたパッケージをimport
します:
import { Temporal } from "@js-temporal/polyfill";
Post
タイプとFedifyが提供するPUBLIC_COLLECTION
定数もimport
します。
import {
Accept,
Endpoints,
Follow,
Note,
PUBLIC_COLLECTION,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
type Recipient,
} from "@fedify/fedify";
import type {
Actor,
Key,
Post
User,
} from "./schema.ts";
マイクロブログの投稿のような短い文章は、ActivityPubでは通常Note
として表現されます。Note
クラスのオブジェクト・ディスパッチャーは既に空の実装を作成していました:
federation.setObjectDispatcher(
Note,
"/users/{identifier}/posts/{id}",
(ctx, values) => {
return null;
},
);
これを以下のように修正します:
federation.setObjectDispatcher(
Note,
"/users/{identifier}/posts/{id}",
(ctx, values) => {
const post = db
.prepare<unknown[], Post>(
`
SELECT posts.*
FROM posts
JOIN actors ON actors.id = posts.actor_id
JOIN users ON users.id = actors.user_id
WHERE users.username = ? AND posts.id = ?
`,
)
.get(values.identifier, values.id);
if (post == null) return null;
return new Note({
id: ctx.getObjectUri(Note, values),
attribution: ctx.getActorUri(values.identifier),
to: PUBLIC_COLLECTION,
cc: ctx.getFollowersUri(values.identifier),
content: post.content,
mediaType: "text/html",
published: Temporal.Instant.from(`${post.created.replace(" ", "T")}Z`),
url: ctx.getObjectUri(Note, values),
});
},
);
Note
オブジェクトを生成する際に設定されるプロパティ値は以下のような役割を果たします:
-
attribution
プロパティにctx.getActorUri(values.identifier)
を設定することで、この投稿の作成者が私たちが作成したアクターであることを示します。 -
to
プロパティにPUBLIC_COLLECTION
を設定することで、この投稿が全体公開の投稿であることを示します。 -
cc
プロパティにctx.getFollowersUri(values.identifier)
を設定することで、この投稿がフォロワーに配信されることを示しますが、これ自体には大きな意味はありません。
それでは、もう一度Mastodonの検索ボックスに投稿のパーマリンク(https://temp-address.serveo.net/users/johndoe/posts/1、ドメイン名は置き換えてください)を入力してみましょう:
今回は検索結果に私たちが作成した投稿が正しく表示されていますね!
しかし、Mastodonで私たちが作成したアクターをフォローしても、新しく作成した投稿はMastodonのタイムラインには表示されません。なぜなら、Mastodonが新しい投稿を自動的に取得するのではなく、新しい投稿を作成した側がCreate(Note)
アクティビティを送信して、新しい投稿が作成されたことを通知する必要があるからです。
投稿作成時にCreate(Note)
アクティビティを送信するようにコードを修正しましょう。src/app.tsxファイルを開き、Fedifyが提供するCreate
クラスをimport
します:
import { Create, Note } from "@fedify/fedify";
そして、POST /users/{username}/posts
リクエストハンドラを次のように修正します:
app.post("/users/:username/posts", async (c) => {
// ... 省略 ...
const ctx = fedi.createContext(c.req.raw, undefined);
const post: Post | null = db.transaction(() => {
const post = db
.prepare<unknown[], Post>(
`
INSERT INTO posts (uri, actor_id, content)
VALUES ('https://localhost/', ?, ?)
RETURNING *
`,
)
.get(actor.id, stringifyEntities(content, { escapeOnly: true }));
if (post == null) return null;
const url = ctx.getObjectUri(Note, {
identifier: username,
id: post.id.toString(),
}).href;
db.prepare("UPDATE posts SET uri = ?, url = ? WHERE id = ?").run(
url,
url,
post.id,
);
return post;
})();
if (post == null) return c.text("Failed to create post", 500);
const noteArgs = { identifier: username, id: post.id.toString() };
const note = await ctx.getObject(Note, noteArgs);
await ctx.sendActivity(
{ identifier: username },
"followers",
new Create({
id: new URL("#activity", note?.id ?? undefined),
object: note,
actors: note?.attributionIds,
tos: note?.toIds,
ccs: note?.ccIds,
}),
);
return c.redirect(ctx.getObjectUri(Note, noteArgs).href);
});
getObject()
メソッドは、オブジェクト・ディスパッチャーが作成するActivityPubオブジェクトを返します。ここではNote
オブジェクトを返すでしょう。そのNote
オブジェクトをCreate
オブジェクトを生成する際にobject
プロパティに設定します。アクティビティの受信者を示すtos
(to
の複数形)およびccs
(cc
の複数形)プロパティは、Note
オブジェクトと同じように設定します。アクティビティのid
は任意の一意なURIを生成して設定します。
ヒント
|
アクティビティオブジェクトのid プロパティには、必ずしもアクセス可能なURIを設定する必要はありません。ただ一意であれば十分です。
|
sendActivity()
メソッドの2番目のパラメータには受信者が入りますが、ここでは"followers"
という特別なオプションを指定しました。このオプションを指定すると、先ほど実装したフォロワーコレクション・ディスパッチャーを使用して、全てのフォロワーにアクティビティを送信します。
さて、実装が完了したので、Create(Note)
アクティビティが正しく送信されるか確認してみましょう。
fedify tunnel
コマンドでローカルサーバーをパブリックインターネットに公開した状態で、ActivityPub.Academyに入り、@[email protected]
(ドメイン名は割り当てられた一時的なドメイン名に置き換えてください)をフォローします。フォロワーリストでフォローリクエストが確実に承認されたことを確認した後、ウェブブラウザでhttps://temp-address.serveo.net/(同様に、ドメイン名は置き換えてください)ページに入り、新しい投稿を作成します。
注意
|
アクティビティ送信をテストする際は、必ずlocalhostではなく、パブリックインターネットからアクセス可能なドメイン名で接続する必要があります。ActivityPubオブジェクトのIDを決定する際、リクエストが来たドメイン名を基準にURIを構築するためです。 |
Create(Note)
アクティビティがうまく送信されたかを確認するために、ActivityPub.AcademyのActivity Logを見てみましょう:
うまく届いていますね。それではActivityPub.Academyでタイムラインを確認してみましょう:
成功しました!
現在のプロフィールページには名前とフェディバースハンドル、フォロワー数のみが表示され、肝心の投稿は表示されていません。プロフィールページで作成した投稿を表示しましょう。
まずsrc/views.tsxファイルを開き、<PostList>
コンポーネントを追加します:
export interface PostListProps {
posts: (Post & Actor)[];
}
export const PostList: FC<PostListProps> = ({ posts }) => (
<>
{posts.map((post) => (
<div key={post.id}>
<PostView post={post} />
</div>
))}
</>
);
そしてsrc/app.tsxファイルを開き、先ほど定義した<PostList>
コンポーネントをimport
します:
import {
FollowerList,
Home,
Layout,
PostList,
PostPage,
Profile,
SetupForm,
} from "./views.tsx";
既存のGET /users/{username}
リクエストハンドラを次のように変更します:
app.get("/users/:username", async (c) => {
// ... 省略 ...
const posts = db
.prepare<unknown[], Post & Actor>(
`
SELECT actors.*, posts.*
FROM posts
JOIN actors ON posts.actor_id = actors.id
WHERE actors.user_id = ?
ORDER BY posts.created DESC
`,
)
.all(user.user_id);
// ... 省略 ...
return c.html(
<Layout>
// ... 省略 ...
<PostList posts={posts} />
</Layout>,
);
});
それでは、ウェブブラウザでhttp://localhost:8000/users/johndoeページを開いてみましょう:
作成した投稿がきちんと表示されているのが確認できます。
現在、我々が作成したアクターは他のサーバーのアクターからフォローリクエストを受け取ることはできますが、他のサーバーのアクターにフォローリクエストを送ることはできません。フォローができないため、他のアクターが作成した投稿も見ることができません。では、他のサーバーのアクターにフォローリクエストを送る機能を追加しましょう。
まずUIから作りましょう。src/views.tsxファイルを開き、既存の<Home>
コンポーネントを次のように修正します:
export const Home: FC<HomeProps> = ({ user }) => (
<>
<hgroup>
{/* ... 省略 ... */}
</hgroup>
<form method="post" action={`/users/${user.username}/following`}>
{/* biome-ignore lint/a11y/noRedundantRoles: PicoCSSがrole=groupを要求します */}
<fieldset role="group">
<input
type="text"
name="actor"
required={true}
placeholder="Enter an actor handle (e.g., @[email protected]) or URI (e.g., https://mastodon.com/@johndoe)"
/>
<input type="submit" value="Follow" />
</fieldset>
</form>
<form method="post" action={`/users/${user.username}/posts`}>
{/* ... 省略 ... */}
</form>
</>
);
トップページが正しく修正されたか確認するために、ウェブブラウザでhttp://localhost:8000/ページを開いてみましょう:
フォローリクエストUIができたので、実際にFollow
アクティビティを送信するコードを書きましょう。
src/app.tsxファイルを開き、Fedifyが提供するFollow
クラスとisActor()
およびlookupObject()
関数をimport
します:
import {
Create,
Follow,
isActor,
lookupObject,
Note,
} from "@fedify/fedify";
そしてPOST /users/{username}/following
リクエストハンドラを追加します:
app.post("/users/:username/following", async (c) => {
const username = c.req.param("username");
const form = await c.req.formData();
const handle = form.get("actor");
if (typeof handle !== "string") {
return c.text("Invalid actor handle or URL", 400);
}
const actor = await lookupObject(handle.trim()); // (1)
if (!isActor(actor)) { // (2)
return c.text("Invalid actor handle or URL", 400);
}
const ctx = fedi.createContext(c.req.raw, undefined);
await ctx.sendActivity( // (3)
{ handle: username },
actor,
new Follow({
actor: ctx.getActorUri(username),
object: actor.id,
to: actor.id,
}),
);
return c.text("Successfully sent a follow request");
});
-
lookupObject()
関数は、アクターを含むActivityPubオブジェクトを検索します。入力としてActivityPubオブジェクトの一意のURIまたはフェディバースハンドルを受け取り、検索したActivityPubオブジェクトを返します。 -
isActor()
関数は、与えられたActivityPubオブジェクトがアクターかどうかを確認します。 -
このコードでは
sendActivity()
メソッドを使用して、検索したアクターにFollow
アクティビティを送信しています。しかし、まだfollows
テーブルにレコードは追加していません。これは、相手からAccept(Follow)
アクティビティを受け取ってからレコードを追加する必要があるためです。
実装したフォローリクエスト機能が正しく動作するか確認する必要があります。今回もアクティビティを送信する必要があるため、fedify tunnel
コマンドを使用してローカルサーバーをパブリックインターネットに公開した後、ウェブブラウザでhttps://temp-address.serveo.net/(ドメイン名は置き換えてください)ページにアクセスします:
フォローリクエスト入力欄にフォローするアクターのフェディバースハンドルを入力する必要があります。ここでは簡単なデバッグのためにActivityPub.Academyのアクターを入力しましょう。参考までに、ActivityPub.Academyにログインした一時アカウントのハンドルは、一時アカウントの名前をクリックしてプロフィールページに入ると、名前のすぐ下に表示されます:
以下のようにActivityPub.Academyのアクターハンドルを入力した後、Followボタンをクリックしてフォローリクエストを送信します:
そしてActivityPub.AcademyのActivity Logを確認します:
Activity Logには我々が送信したFollow
アクティビティと、ActivityPub.Academyから送信された返信であるAccept(Follow)
アクティビティが表示されます。
ActivityPub.Academyの通知ページに行くと、実際にフォローリクエストが到着したことを確認できます:
しかし、まだ受信したAccept(Follow)
アクティビティに対して何の動作も取っていないため、この部分を実装する必要があります。
src/federation.tsファイルを開き、Fedifyが提供するisActor()
関数およびActor
型をimport
します:
import {
Accept,
Endpoints,
Follow,
Note,
PUBLIC_COLLECTION,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
isActor,
type Actor as APActor, // (1)
type Recipient,
} from "@fedify/fedify";
-
このソースファイル内で
Actor
型の名前が重複するため、APActor
というエイリアスを付けました。
実装に先立ち、初めて遭遇したアクター情報をactors
テーブルに挿入するコードをリファクタリングして再利用可能にしましょう。以下の関数を追加します:
async function persistActor(actor: APActor): Promise<Actor | null> {
if (actor.id == null || actor.inboxId == null) {
logger.debug("Actor is missing required fields: {actor}", { actor });
return null;
}
return (
db
.prepare<unknown[], Actor>(
`
-- アクターレコードを新規追加するか、既に存在する場合は更新
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(
actor.id.href,
await getActorHandle(actor),
actor.name?.toString(),
actor.inboxId.href,
actor.endpoints?.sharedInbox?.href,
actor.url?.href,
) ?? null
);
}
定義したpersistActor()
関数は、引数として渡されたアクターオブジェクトに対応するレコードをactors
テーブルに追加します。既にテーブルに対応するレコードが存在する場合は、レコードを更新します。
受信トレイのon(Follow, …)
部分で同じ役割を果たすコードをpersistActor()
関数を使用するように変更します:
federation
.setInboxListeners("/users/{identifier}/inbox", "/inbox")
.on(Follow, async (ctx, follow) => {
// ... 省略 ...
if (followingId == null) {
logger.debug(
"Failed to find the actor to follow in the database: {object}",
{ object },
);
}
const followerId = (await persistActor(follower))?.id;
db.prepare(
"INSERT INTO follows (following_id, follower_id) VALUES (?, ?)",
).run(followingId, followerId);
// ... 省略 ...
})
リファクタリングが終わったら、受信トレイにAccept(Follow)
アクティビティを受け取ったときに取るべき動作を実装します:
.on(Accept, async (ctx, accept) => {
const follow = await accept.getObject();
if (!(follow instanceof Follow)) return;
const following = await accept.getActor();
if (!isActor(following)) return;
const follower = follow.actorId;
if (follower == null) return;
const parsed = ctx.parseUri(follower);
if (parsed == null || parsed.type !== "actor") return;
const followingId = (await persistActor(following))?.id;
if (followingId == null) return;
db.prepare(
`
INSERT INTO follows (following_id, follower_id)
VALUES (
?,
(
SELECT actors.id
FROM actors
JOIN users ON actors.user_id = users.id
WHERE users.username = ?
)
)
`,
).run(followingId, parsed.identifier);
});
妥当性を検査するコードが長いですが、要約するとAccept(Follow)
アクティビティの内容からフォローリクエストを送信したアクター(follower
)とフォローリクエストを受け取ったアクター(following
)を取得し、follows
テーブルにレコードを追加するものです。
これで正しく動作するか確認する必要がありますが、問題があります。先ほどフォローリクエストを送信したとき、file:https://activitypub.academy/[ActivityPub.Academy]側ではフォローリクエストを承認し、Accept(Follow)
アクティビティを既に送信しているため、この状態でもう一度フォローリクエストを送信しても無視されてしまいます。したがって、ActivityPub.Academyからログアウトした後、再度一時アカウントを作成してテストする必要があります。
ActivityPub.Academyで新しい一時アカウントを作成したら、fedify tunnel
コマンドでローカルサーバーをパブリックインターネットに公開した状態で、ウェブブラウザでhttps://temp-address.serveo.net/(ドメイン名は置き換えてください)ページにアクセスし、ActivityPub.Academyの新しい一時アカウントにフォローリクエストを送信します。
フォローリクエストが正しく送信されたら、先ほどと同様にActivity LogにFollow
アクティビティが到着した後、返信としてAccept(Follow)
アクティビティが発信されたのが確認できるはずです:
まだフォローリストを実装していないため、follows
テーブルにレコードが正しく挿入されたか直接確認してみましょう:
$ echo "SELECT * FROM follows WHERE follower_id = 1;" | sqlite3 -table microblog.sqlite3
成功していれば、以下のような結果が得られるはずです(following_id
列の値は多少異なる可能性があります):
following_id |
follower_id |
created |
---|---|---|
|
|
|
我々が作成したアクターがフォローしているアクターのリストを表示するページを作成しましょう。
まずsrc/views.tsxファイルを開き、<FollowingList>
コンポーネントを追加します:
export interface FollowingListProps {
following: Actor[];
}
export const FollowingList: FC<FollowingListProps> = ({ following }) => (
<>
<h2>Following</h2>
<ul>
{following.map((actor) => (
<li key={actor.id}>
<ActorLink actor={actor} />
</li>
))}
</ul>
</>
);
次に、src/app.tsxファイルを開き、先ほど定義した<FollowingList>
コンポーネントをインポートします:
import {
FollowerList,
FollowingList,
Home,
Layout,
PostList,
PostPage,
Profile,
SetupForm,
} from "./views.tsx";
そしてGET /users/{username}/following
リクエストに対するハンドラーを追加します:
app.get("/users/:username/following", async (c) => {
const following = db
.prepare<unknown[], Actor>(
`
SELECT following.*
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 = followers.user_id
WHERE users.username = ?
ORDER BY follows.created DESC
`,
)
.all(c.req.param("username"));
return c.html(
<Layout>
<FollowingList following={following} />
</Layout>,
);
});
正しく実装されたかどうか確認するために、ウェブブラウザでhttp://localhost:8000/users/johndoe/followingページを開いてみましょう:
フォロワー数を表示しているのと同様に、フォロー数も表示する必要があります。
src/views.tsxファイルを開き、<Profile>
コンポーネントを次のように修正します:
export interface ProfileProps {
name: string;
username: string;
handle: string;
following: number;
followers: number;
}
export const Profile: FC<ProfileProps> = ({
name,
username,
handle,
following,
followers,
}) => (
<>
<hgroup>
<h1>
<a href={`/users/${username}`}>{name}</a>
</h1>
<p>
<span style="user-select: all;">{handle}</span> ·{" "}
<a href={`/users/${username}/following`}>{following} following</a>{" "}
·{" "}
<a href={`/users/${username}/followers`}>
{followers === 1 ? "1 follower" : `${followers} followers`}
</a>
</p>
</hgroup>
</>
);
<PostPage>
コンポーネントも次のように修正します:
export interface PostPageProps extends ProfileProps, PostViewProps {}
export const PostPage: FC<PostPageProps> = (props) => (
<>
<Profile
name={props.name}
username={props.username}
handle={props.handle}
following={props.following}
followers={props.followers}
/>
<PostView post={props.post} />
</>
);
では、実際にデータベースを照会してフォロー数を取得するコードを書く必要があります。
src/app.tsxファイルを開き、GET /users/{username}
リクエストに対するハンドラーを次のように修正します:
app.get("/users/:username", async (c) => {
// ... 省略 ...
if (user == null) return c.notFound();
// biome-ignore lint/style/noNonNullAssertion: 常に1つのレコードを返す
const { following } = db
.prepare<unknown[], { following: number }>(
`
SELECT count(*) AS following
FROM follows
JOIN actors ON follows.follower_id = actors.id
WHERE actors.user_id = ?
`,
)
.get(user.id)!;
// ... 省略 ...
return c.html(
<Layout>
<Profile
name={user.name ?? user.username}
username={user.username}
handle={handle}
following={following}
followers={followers}
/>
<PostList posts={posts} />
</Layout>,
);
});
GET /users/{username}/posts/{id}
リクエストハンドラーも修正します:
app.get("/users/:username/posts/:id", (c) => {
// ... 省略 ...
if (post == null) return c.notFound();
// biome-ignore lint/style/noNonNullAssertion: 常に1つのレコードを返す
const { following, followers } = db
.prepare<unknown[], { following: number; followers: number }>(
`
SELECT sum(follows.follower_id = ?) AS following,
sum(follows.following_id = ?) AS followers
FROM follows
`,
)
.get(post.actor_id, post.actor_id)!;
return c.html(
<Layout>
<PostPage
name={post.name ?? post.username}
username={post.username}
handle={post.handle}
following={following}
followers={followers}
post={post}
/>
</Layout>,
);
});
全て修正が完了したら、ウェブブラウザでhttp://localhost:8000/users/johndoeページを開いてみましょう:
多くの機能を実装しましたが、まだ他のMastodonサーバーで書かれた投稿は表示されていません。これまでの過程から推測できるように、我々が投稿を作成したときにCreate(Note)
アクティビティを送信したのと同様に、他のサーバーからCreate(Note)
アクティビティを受信する必要があります。
他のMastodonサーバーで投稿を作成したときに具体的に何が起こるかを見るために、ActivityPub.Academyで新しい投稿を作成してみましょう:
Publish!ボタンをクリックして投稿を保存した後、Activity Logページに移動してCreate(Note)
アクティビティが確かに送信されたかどうか確認します:
これで、このように送信されたCreate(Note)
アクティビティを受信するコードを書く必要があります。
src/federation.tsファイルを開き、Fedifyが提供するCreate
クラスをインポートします:
import {
Accept,
Create,
Endpoints,
Follow,
Note,
PUBLIC_COLLECTION,
Person,
Undo,
createFederation,
exportJwk,
generateCryptoKeyPair,
getActorHandle,
importJwk,
isActor,
type Actor as APActor,
type Recipient,
} from "@fedify/fedify";
そして受信トレイのコードにon(Create, …)
を追加します:
.on(Create, async (ctx, create) => {
const object = await create.getObject();
if (!(object instanceof Note)) return;
const actor = create.actorId;
if (actor == null) return;
const author = await object.getAttribution(); // (1)
if (!isActor(author) || author.id?.href !== actor.href) return;
const actorId = (await persistActor(author))?.id; // (2)
if (actorId == null) return;
if (object.id == null) return;
const content = object.content?.toString();
db.prepare( // (3)
"INSERT INTO posts (uri, actor_id, content, url) VALUES (?, ?, ?, ?)",
).run(object.id.href, actorId, content, object.url?.href);
});
-
getAttribution()
メソッドを使用して投稿者を取得した後、 -
persistActor()
関数を通じてそのアクターがまだactors
テーブルに存在しない場合は追加します。 -
そして
posts
テーブルに新しいレコードを1つ追加します。
コードが正しく動作するかどうか確認するために、もう一度ActivityPub.Academyに入って投稿を作成してみましょう。Activity Logを開いてCreate(Note)
アクティビティが送信されたことを確認した後、以下のコマンドでposts
テーブルに本当にレコードが追加されたかどうか確認します:
$ echo "SELECT * FROM posts WHERE actor_id != 1" | sqlite3 -table microblog.sqlite3
実際にレコードが追加されていれば、以下のような結果が表示されるはずです:
id |
uri |
actor_id |
content |
url |
created |
---|---|---|---|---|---|
|
|
|
|
|
|
さて、これでリモート投稿をposts
テーブルにレコードとして追加しましたので、あとはそれらのレコードを適切に表示するだけです。一般的に「タイムライン」と呼ばれる機能です。
まずsrc/views.tsxファイルを開き、<Home>
コンポーネントを修正します:
export interface HomeProps extends PostListProps { // (1)
user: User & Actor;
}
export const Home: FC<HomeProps> = ({ user, posts }) => (
<>
{/* ... 省略 ... */}
<PostList posts={posts} /> // (2)
</>
);
-
extends PostListProps
を追加 -
<PostList>
コンポーネントを追加
その後、src/app.tsxファイルを開いてGET /
リクエストハンドラーを修正します:
app.get("/", (c) => {
// ... 省略 ...
if (user == null) return c.redirect("/setup");
const posts = db
.prepare<unknown[], Post & Actor>(
`
SELECT actors.*, posts.*
FROM posts
JOIN actors ON posts.actor_id = actors.id
WHERE posts.actor_id = ? OR posts.actor_id IN (
SELECT following_id
FROM follows
WHERE follower_id = ?
)
ORDER BY posts.created DESC
`,
)
.all(user.id, user.id);
return c.html(
<Layout>
<Home user={user} posts={posts} />
</Layout>,
);
});
さて、これで全て実装できましたので、ウェブブラウザでhttp://localhost:8000/ページを開いてタイムラインを鑑賞しましょう:
上のように、リモートで作成された投稿とローカルで作成された投稿が最新順に適切に表示されていることがわかります。どうでしょうか?気に入りましたか?
このチュートリアルで実装する内容は以上です。これを基に皆さん自身のマイクロブログを完成させることも可能でしょう。
このチュートリアルを通じて完成した皆さんのマイクロブログは、残念ながらまだ実際の使用には適していません。特にセキュリティ面で多くの脆弱性があるため、実際に使用するのは危険かもしれません。
皆さんが作成したマイクロブログをさらに発展させたい方は、以下の課題を自分で解決してみるのもよいでしょう:
-
現在は認証が一切ないため、誰でもURLさえ知っていれば投稿ができてしまいます。ログインプロセスを追加してこれを防ぐにはどうすればよいでしょうか?
-
現在の実装では、ActivityPubを通じて受け取った
Note
オブジェクト内のHTMLをそのまま出力するようになっています。そのため、悪意のあるActivityPubサーバーが<script>while (true) alert('べー');</script>
のようなHTMLを含むCreate(Note)
アクティビティを送信する攻撃が可能です。これはXSS脆弱性と呼ばれます。このような脆弱性はどのように防ぐことができるでしょうか? -
SQLiteデータベースで次のSQLを実行して、私たちが作成したアクターの名前を変更してみましょう:
UPDATE actors SET name = 'Renamed' WHERE id = 1;
このようにアクターの名前を変更した場合、他のMastodonサーバーで変更された名前が適用されるでしょうか?適用されない場合、どのようなアクティビティを送信すれば変更が適用されるでしょうか?
-
アクターにプロフィール画像を追加してみましょう。プロフィール画像を追加する方法が気になる場合は、
fedify lookup
コマンドを使用して既にプロフィール画像があるアクターを検索してみてください。 -
他のMastodonサーバーで画像が添付された投稿を作成してみましょう。私たちが作成したタイムラインでは、投稿に添付された画像が表示されません。どうすれば添付された画像を表示できるでしょうか?
-
投稿内で他のアクターをメンションできるようにしてみましょう。メンションした相手に通知を送るにはどうすればよいでしょうか?ActivityPub.AcademyのActivity Logを活用して方法を探してみてください。
1988年ソウル生まれ。2000年からウェブ開発を始め、主にPython、Haskell、C#、TypeScript等の言語を使用。オープンソースとフェディバースの熱烈な支持者であり、ActivityPubサーバーフレームワークであるFedifyと、Fedifyベースの一人用ActivityPub実装であるHolloを開発。
-
ウェブサイト:https://hongminhee.org/
-
メールアドレス:[email protected]
-
フェディバース(日本語):@[email protected]
-
フェディバース(英語):@[email protected]
本書はクリエイティブ・コモンズ表示-継承4.0国際ライセンスの下に提供されています。
本書はAsciiDocで組版されており、以下のGitHubリポジトリからソースコードとPDF本を入手できます: