Skip to content

dahlia/fedify-microblog-tutorial-ja

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

31 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

自分だけのフェディバースのマイクロブログを作ろう!

目次

序文

ヒント

本書はAsciiDocで組版されており、​以下のGitHubリポジトリからソースコードとPDF本を入手できます:

このチュートリアルに入る前に、​フェディバースとActivityPubについて簡単に説明しましょう。

フェディバースとは?

フェディバース(fediverse)は、​“federation”(連合)と“universe”(宇宙)を組み合わせた造語で、​相互にやり取りができる分散型のソーシャルネットワークの集合体を指します。​従来の中央集権型のソーシャルメディアプラットフォーム(例:X、​Facebook)とは異なり、​フェディバースは多数の独立したサーバー(インスタンス)で構成されています。

フェディバースの主な特徴:

  • 分散型:単一の企業や団体が管理するのではなく、​世界中の個人や組織が運営する多数のサーバーで構成されています。

  • 相互運用性:異なるサーバー上のユーザーが相互にコミュニケーションを取ることができます。

  • オープンソース:多くのフェディバースソフトウェアはオープンソースで、​誰でも自由に使用​・​改変できます。

  • データの自己管理:ユーザーは自分のデータをより直接的に管理できます。

フェディバースの代表的なソフトウェアには、​Mastodon、​MetaのThreads、​Misskey、​Pixelfedなどがあります。

ActivityPubとは?

ActivityPubは、​​フェディバースを支える重要な技術標準の一つです。​​これは、​​ソーシャルネットワーキングプロトコルであり、​​異なるサーバー間でのアクティビティ(投稿、​​コメント、​​いいね等)の共有方法を定義しています。

ActivityPubの主な特徴:

  • W3C勧告:World Wide Web Consortium(W3C)によって標準化されています。

  • クライアント-サーバー通信:ユーザーのクライアントアプリとサーバー間の通信を定義します。

  • サーバー間通信:異なるサーバー間でのアクティビティの配信方法を規定します。

  • JSON-LD形式:データはJSON for Linked Data (JSON-LD)形式で表現されます。

ActivityPubを採用することで、​異なるソフトウェア間での相互運用性が実現され、​ユーザーは自分の選んだサービスを使いながら、​他のサービスのユーザーとコミュニケーションを取ることができます。

このチュートリアルでは、​ActivityPubサーバーフレームワークであるFedifyを使用して、​MastodonMisskeyのようなActivityPubプロトコルを実装するマイクロブログ(microblog)を作成します。​ このチュートリアルは、​Fedifyの基本的な動作原理を理解するよりも、​Fedifyの活用方法により焦点を当てています。

Fedifyとは?

Fedifyのロゴ
図 1. Fedifyのロゴ

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リポジトリにアップロードされており、​各実装段階に応じてコミットが分かれていますので、​参考にしてください。

開発環境のセットアップ

Node.jsのインストール

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プロジェクトをセットアップするために、​fedifyコマンドをシステムにインストールする必要があります。​複数のインストール方法がありますが、​npmコマンドを使用するのが最も簡単です:

$ npm install -g @fedify/cli

インストールが完了したら、​fedifyコマンドが使用可能かどうか確認しましょう。​以下のコマンドでfedifyコマンドのバージョンを確認できます。

$ fedify --version

表示されたバージョン番号が1.0.0以上であることを確認してください。​それより古いバージョンだと、​このチュートリアルを正しく進めることができません。

fedify initでプロジェクトの初期化

新しい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であることがわかります。

ヒント

fedify lookupはActivityPubオブジェクトを照会するコマンドです。​これはMastodonで該当URIを検索するのと同じ動作をします。​(もちろん、​現在皆さんのサーバーはローカルでのみアクセス可能なため、​まだMastodonで検索しても結果は表示されません)

fedify lookupコマンドよりもcurlを好む場合は、​以下のコマンドでもアクター照会が可能です(-HオプションでAcceptヘッダーを一緒に送信することに注意してください):

$ curl -H"Accept: application/activity+json" http://localhost:8000/users/john

ただし、​上記のように照会すると、​その結果は人間の目で確認しにくいJSON形式になります。​システムにjqコマンドもインストールされている場合は、​curljqを組み合わせて使用することもできます:

$ curl -H"Accept: application/activity+json" http://localhost:8000/users/john | jq .

Visual Studio Code

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に慣れている方は、​この章をスキップしても構いません。

TypeScriptはJavaScriptに静的型チェックを追加したものです。​TypeScriptの文法はJavaScriptの文法とほぼ同じですが、​変数や関数の文法に型を指定できるという大きな違いがあります。​型指定は変数やパラメータの後にコロン(:)をつけて表します。

例えば、​次のコードはfoo変数が文字列(string)であることを示しています:

let foo: string;

上記のように宣言されたfoo変数に文字列以外の型の値を代入しようとすると、​Visual Studio Codeが実行する前に赤い下線を引いて型エラーを表示します:

foo = 123;  // (1)
  1. ts(2322): 型numberを型stringに割り当てることはできません。

コーディング中に赤い下線が表示されたら、​無視せずに対処してください。​無視してプログラムを実行すると、​その部分で実際にエラーが発生する可能性が高いです。

TypeScriptでコーディングをしていて最も頻繁に遭遇する型エラーは、​nullの可能性があるエラーです。​例えば、​次のコードではbar変数が文字列(string)である可能性もあればnullである可能性もある(string | null)と示されています:

const bar: string | null = someFunction();

この変数の内容から最初の文字を取り出そうとして、​次のようなコードを書くとどうなるでしょうか:

const firstChar = bar.charAt(0);  // (1)
  1. ts(18047): barnullの可能性があります。

上記のように型エラーが発生します。​barが場合によってはnullである可能性があり、​その場合にnull.charAt(0)を呼び出すとエラーが発生する可能性があるため、​コードを修正するよう指摘しています。​このような場合、​以下のようにnullの場合の処理を追加する必要があります:

const firstChar = bar === null ? "" : bar.charAt(0);

このように、​TypeScriptはコーディング時に気づかなかった場合の数を想起させ、​バグを未然に防ぐのに役立ちます。

また、​TypeScriptの副次的な利点の一つは、​自動補完が機能することです。​例えば、​foo.まで入力すると、​文字列オブジェクトが持つメソッドのリストが表示され、​その中から選択できます。​これにより、​一々ドキュメントを確認しなくても迅速にコーディングが可能になります。

このチュートリアルを進めながら、​TypeScriptの魅力も一緒に感じていただければと思います。​何より、​FedifyはTypeScriptと一緒に使用したときに最も良い体験が得られるのです。

ヒント
TypeScriptをしっかりじっくり学びたい場合は、​公式のTypeScriptハンドブック(英語)を読むことをお勧めします。​全部読むのに約30分ほどかかります。

JSX

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インターフェースを宣言して使用しています。

注釈

ジェネリック型の型引数は複数になる場合があり、​カンマで各引数を区切ります。​例えば、​Foo<A, B>はジェネリック型Fooに型引数ABを適用したものです。

また、​ジェネリック関数というものもあり、​someFunction<A, B>(foo, bar)のように表記します。

型引数が1つの場合、​型引数を囲む山括弧がXML/HTMLタグのように見えますが、​JSXの機能とは無関係です。

  • FC<ContainerProps>:ジェネリック型FCに型引数ContainerPropsを適用したもの。

  • <Container><Container>という名前のコンポーネントタグを開いたもの。​</Container>で閉じる必要があります。

プロップとして渡されるもののうち、​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ページを開いてみましょう。​以下のような画面が表示されれば正常です:

アカウント作成ページ
図 2. アカウント作成ページ
注釈
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)
  1. DebianおよびUbuntu

  2. FedoraおよびRHEL

  3. Chocolatey

  4. Scoop

  5. 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;
ヒント

参考までに、​db.pragma()関数を通じて設定した内容は以下のような効果があります:

  • journal_mode = WAL:SQLiteでアトミックなコミットとロールバックを実装する方法としてログ先行書き込みモードを採用します。​このモードは、​デフォルトのロールバックジャーナルモードに比べて、​ほとんどの場合でパフォーマンスが優れています。

  • foreign_keys = ON:SQLiteではデフォルトで外部キー制約をチェックしません。​この設定をオンにすると外部キー制約をチェックするようになり、​データの整合性を保つのに役立ちます。

そして、​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

1

johndoe

プロフィールページ

これでアカウントが作成されたので、​アカウント情報を表示するプロフィールページを実装しましょう。​表示する情報はほとんどありませんが。

今回も見える部分から作業を始めましょう。​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を変更する必要があります)。​以下のような画面が表示されるはずです:

プロフィールページ
図 3. プロフィールページ
ヒント

フェディバースハンドル(fediverse handle)、​略してハンドルとは、​フェディバース内でアカウントを指す一意なアドレスのようなものです。​例えば@[email protected]のような形をしています。​メールアドレスに似た形をしていますが、​実際の構成もメールアドレスに似ています。​最初に@が来て、​その後に名前、​そして再び@が来た後、​最後にアカウントが属するサーバーのドメイン名が来ます。​時々、​最初の@が省略されることもあります。

技術的には、​ハンドルはWebFingeracct: URI形式という2つの標準で実装されています。​Fedifyがこれを実装しているため、​このチュートリアルを進める間は実装の詳細を知らなくても大丈夫です。

アクターの実装

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 <> '')
);
  1. user_idカラムはusersカラムと連携するための外部キーです。​該当レコードがリモートアクターを表す場合はNULLが入りますが、​現在のインスタンスサーバーのアカウントの場合は該当アカウントのusers.id値が入ります。

  2. uriカラムはアクターIDと呼ばれるアクターの一意なURIを含みます。​アクターを含むすべてのActivityPubオブジェクトはURI形式の一意なIDを持ちます。​したがって、​空にすることはできず、​重複もできません。

  3. handleカラムは@[email protected]形式のフェディバースハンドルを含みます。​同様に、​空にすることはできず、​重複もできません。

  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データベースファイルに適用しましょう:

$ 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テーブルにレコードを追加しなかったためです。​アカウント作成コードを修正してusersactorsの両方にレコードを追加するようにする必要があります。

まず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.tsximportします:

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 & BA型と同時に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ページを開いて再度アカウントを作成します:

アカウント作成ページ
図 4. アカウント作成ページ

アクターディスパッチャー

actorsテーブルを作成してレコードも追加したので、​再びsrc/federation.tsファイルを修正しましょう。​まずdbオブジェクトとEndpointsおよびActorimportします:

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カラムの値を使用します。​inboxendpoints属性はインボックスについて説明するときに一緒に扱うことにします。​url属性はこのアカウントのプロフィールURLを含みますが、​このチュートリアルではアクターIDとアクターのプロフィールURLを一致させることにします。

ヒント

目のいい方々は気づいたかもしれませんが、​HonoとFedify両方でGET /users/{identifier}に対するハンドラを重複して定義しています。​では、​実際にそのリクエストを送信すると、​どちらが応答することになるでしょうか?答えは、​リクエストのAcceptヘッダーによって異なります。​Accept: text/htmlヘッダーと一緒にリクエストを送信すると、​Hono側のリクエストハンドラが応答します。​Accept: application/activity+jsonヘッダーと一緒にリクエストを送信すると、​Fedify側のリクエストハンドラが応答します。

このようにリクエストのAcceptヘッダーに応じて異なる応答を返す方式をHTTPのコンテンツネゴシエーション(content negotiation)と呼び、​Fedify自体がコンテンツネゴシエーションを実装しています。​より具体的には、​すべてのリクエストは一度Fedifyを通過し、​ActivityPubに関連するリクエストでない場合は連携されたフレームワーク、​このチュートリアルではHonoにリクエストを渡すようになっています。

ヒント
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)
}
  1. 秘密鍵

  2. 公開鍵

  3. 鍵の一意な識別URI

  4. 公開鍵の別の形式

  5. 公開鍵のさらに別の形式

CryptoKeyCryptographicKeyMultikeyがそれぞれどう違うのか、​なぜこのように複数の形式が必要なのかは、​ここで説明するには複雑すぎます。​ただ、​現時点ではPersonオブジェクトを初期化する際にpublicKey属性はCryptographicKey形式を受け取り、​assertionMethods属性はMultikey[]Multikeyの配列をTypeScriptでこのように表記)形式を受け取るということだけ覚えておきましょう。

ところで、​Personオブジェクトには公開鍵を持つ属性がpublicKeyassertionMethodsの2つもあるのはなぜでしょうか?ActivityPubには元々publicKey属性しかありませんでしたが、​後から複数の鍵を登録できるようにassertionMethods属性が追加されました。​先ほどRSA-PKCS#1-v1.5形式とEd25519形式の鍵を両方生成したのと同じような理由で、​様々なソフトウェアとの互換性のために両方の属性を設定しているのです。​よく見ると、​レガシーな属性であるpublicKeyにはレガシーな鍵形式であるRSA-PKCS#1-v1.5鍵のみを登録していることがわかります。​(配列の最初の項目にRSA-PKCS#1-v1.5鍵ペアが、​2番目の項目にEd25519鍵ペアが入ります)

ヒント

実はpublicKey属性も複数の鍵を含めることはできます。​しかし、​多くのソフトウェアが既にpublicKey属性には単一の鍵しか入らないという前提で実装されているため、​誤動作することが多いのです。​これを避けるためにassertionMethodsという新しい属性が提案されたのです。

これに関して興味が湧いた方は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との連携

これで実際の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(皆さんの固有の一時ドメインに置き換えてください)を開いて、​きちんとアクセスできるか確認できます:

公開インターネットに露出されたプロフィールページ
図 5. 公開インターネットに露出されたプロフィールページ

上記のウェブページに表示されている皆さんのフェディバースハンドルをコピーした後、​Mastodonに入って左上にある検索ボックスに貼り付けて検索してみてください:

Mastodonでフェディバースハンドルで検索した結果
図 6. Mastodonでフェディバースハンドルで検索した結果

上記のように検索結果に我々が作成したアクターが表示されれば正常です。​検索結果でアクターの名前をクリックしてプロフィールページに入ることもできます:

Mastodonで見るアクターのプロフィール
図 7. 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;
}

Followアクティビティの受信

これでインボックスを実装する番です。​実は、​すでに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)アクティビティを返信として送ります。​第一パラメータに送信者、​第二パラメータに受信者、​第三パラメータに送信するアクティビティオブジェクトを受け取ります。

ActivityPub.Academy

さて、​それではフォローリクエストが正しく受信されるか確認しましょう。

通常のMastodonサーバーでテストしても問題ありませんが、​アクティビティがどのように行き来するか具体的に確認できるActivityPub.Academyサーバーを利用することにします。​ActivityPub.Academyは教育およびデバッグ目的の特殊なMastodonサーバーで、​クリック一つで簡単に一時的なアカウントを作成できます。

ActivityPub.Academyの最初のページ
図 8. ActivityPub.Academyの最初のページ

プライバシーポリシーに同意した後、​登録するボタンを押して新しいアカウントを作成します。​作成されたアカウントはランダムに生成された名前とハンドルを持ち、​一日が経過すると自動的に消えます。​代わりに、​アカウントは何度でも新しく作成できます。

ログインが完了したら、​画面の左上にある検索ボックスに我々が作成したアクターのハンドルを貼り付けて検索します:

ActivityPub.Academyで我々が作成したアクターのハンドルで検索した結果
図 9. ActivityPub.Academyで我々が作成したアクターのハンドルで検索した結果

我々が作成したアクターが検索結果に表示されたら、​右側にあるフォローボタンを押してフォローリクエストを送ります。​そして右側のメニューからActivity Logをクリックします:

ActivityPub.AcademyのActivity Log
図 10. ActivityPub.AcademyのActivity Log

すると、​先ほどフォローボタンを押したことでActivityPub.Academyサーバーから我々が作成したアクターのインボックスにFollowアクティビティが送信されたという表示が見えます。​右下のshow sourceをクリックするとアクティビティの内容まで見ることができます:

Activity Logでshow sourceを押した画面
図 11. Activity Logでshow sourceを押した画面

テスト

アクティビティがきちんと送信されたことを確認したので、​実際に我々が書いたインボックスコードがうまく動作したか確認する番です。​まずfollowsテーブルにレコードがきちんと作成されたか見てみましょう:

$ echo "SELECT * FROM follows;" | sqlite3 -table microblog.sqlite3

フォローリクエストがきちんと処理されていれば、​次のような結果が出力されるはずです(もちろん、​時刻は異なるでしょう):

following_id follower_id created

1

2

2024-09-01 10:19:41

果たして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

2

https://activitypub.academy/users/dobussia_dovornath

@[email protected]

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)アクティビティ
図 12. Activity Logに表示されたAccept(Follow)アクティビティ

さて、​これで皆さんは初めてActivityPubを通じた相互作用を実装しました!

フォロー解除

他のサーバーのアクターが我々が作成したアクターをフォローした後、​再び解除するとどうなるでしょうか?ActivityPub.Academyで試してみましょう。​先ほどと同様に、​ActivityPub.Academyの検索ボックスに我々が作成したアクターのフェディバースハンドルを入力して検索します:

ActivityPub.Academyの検索結果
図 13. ActivityPub.Academyの検索結果

よく見ると、​アクター名の右側にあったフォローボタンの場所にフォロー解除(unfollow)ボタンがあります。​このボタンを押してフォローを解除した後、​Activity Logに入ってどのようなアクティビティが送信されるか確認してみましょう:

送信されたUndo(Follow)アクティビティが表示されているActivity Log
図 14. 送信されたUndo(Follow)アクティビティが表示されている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)アクティビティを受信した時の動作を何も定義していないため、​何も起こりませんでした。

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

1

2

2024-09-02 01:05:17

そして再度フォロー解除ボタンを押した後、​データベースをもう一度確認します:

$ echo "SELECT count(*) FROM follows;" | sqlite3 -table microblog.sqlite3

フォロー解除リクエストがきちんと処理されていれば、​レコードが消えているので次のような結果が出力されるはずです:

count(*)

0

フォロワーリスト

毎回フォロワーリストを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ページを開くと、​以下のように表示されるはずです:

フォロワーリストページ
図 15. フォロワーリストページ

フォロワーリストを作成したので、​プロフィールページでフォロワー数も表示すると良いでしょう。​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> &middot;{" "}
        <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ページを開くと以下のように表示されるはずです:

変更されたプロフィールページ
図 16. 変更されたプロフィールページ

フォロワーコレクション

しかし、​一つ問題があります。​ActivityPub.Academy以外の他のMastodonサーバーから我々が作成したアクターを照会してみましょう。​(照会方法はもうご存知ですよね?公開インターネットに露出された状態で、​アクターのハンドルをMastodonの検索ボックスに入力すれば良いのです)Mastodonで我々が作成したアクターのプロフィールを見ると、​おそらく奇妙な点に気づくでしょう:

Mastodonで照会した我々が作成したアクターのプロフィール
図 17. 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;
}
  1. id属性にはアクターの一意なIRIが入り、​

  2. inboxIdにはアクターの個人インボックスURLが入ります。

  3. 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で我々が作成したアクターを照会してみましょう。​しかし、​その結果は少し落胆させられるかもしれません:

Mastodonで再度照会した我々が作成したアクターのプロフィール
図 18. 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)
);
  1. idカラムはテーブルの主キーです。

  2. uriカラムは投稿の一意なURIを含みます。​先ほど述べたように、​ActivityPubオブジェクトはすべて一意なURIを持つ必要があるためです。

  3. actor_idカラムは投稿を作成したアクターを指します。

  4. contentカラムには投稿の内容を含みます。

  5. urlカラムにはウェブブラウザで投稿を表示するURLを含みます。​ActivityPubオブジェクトのURIとウェブブラウザに表示されるページのURLが一致する場合もありますが、​そうでない場合もあるため、​別のカラムが必要です。​ただし、​空である可能性もあります。

  6. 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/ページを開くと以下のように表示されるはずです:

トップページ
図 19. トップページ

レコードの挿入

投稿作成フォームを作成したので、​実際に投稿内容を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/ページを開いた後、​投稿を作成してみましょう:

投稿作成中
図 20. 投稿作成中

Postボタンを押して投稿を作成すると、​残念ながら404 Not Foundエラーが発生します:

404 Not Found
図 21. 404 Not Found

というのも、​投稿パーマリンクにリダイレクトするよう実装したのに、​まだ投稿ページを実装していないからです。​しかし、​それでもpostsテーブルにはレコードが作成されているはずです。​一度確認してみましょう:

$ echo "SELECT * FROM posts;" | sqlite3 -table microblog.sqlite3

すると次のような結果が出力されるはずです:

id uri actor_id content url created

1

http://localhost:8000/users/johndoe/posts/1

1

It’s my first post!

http://localhost:8000/users/johndoe/posts/1

2024-09-02 08:10:55

投稿ページ

投稿作成後に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ページをウェブブラウザで開いてみましょう:

投稿ページ
図 22. 投稿ページ

Noteオブジェクト・ディスパッチャー

それでは、​他のMastodonのサーバーで投稿を照会できるか確認してみましょう。​まず、​fedify tunnelを利用してローカルサーバーを公開インターネットに露出します。

その状態で、​Mastodonの検索ボックスに記事のパーマリンクであるhttps://temp-address.serveo.net/users/johndoe/posts/1(皆さんの固有の一時ドメインに置き換えてください)を入力してみます:

空の検索結果
図 23. 空の検索結果

残念ながら、​検索結果は空です。​投稿を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検索結果に作成した投稿が表示される
図 24. Mastodon検索結果に作成した投稿が表示される

今回は検索結果に私たちが作成した投稿が正しく表示されていますね!

Create(Note)アクティビティの送信

しかし、​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プロパティに設定します。​アクティビティの受信者を示すtostoの複数形)およびccsccの複数形)プロパティは、​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を見てみましょう:

受信されたCreate(Note)アクティビティが表示されているActivity Log
図 25. 受信されたCreate(Note)アクティビティが表示されているActivity Log

うまく届いていますね。​それではActivityPub.Academyでタイムラインを確認してみましょう:

ActivityPub.Academyのタイムラインに作成した投稿がよく表示されている
図 26. 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ページを開いてみましょう:

変更されたプロフィールページ
図 27. 変更されたプロフィールページ

作成した投稿がきちんと表示されているのが確認できます。

フォロー

現在、​我々が作成したアクターは他のサーバーのアクターからフォローリクエストを受け取ることはできますが、​他のサーバーのアクターにフォローリクエストを送ることはできません。​フォローができないため、​他のアクターが作成した投稿も見ることができません。​では、​他のサーバーのアクターにフォローリクエストを送る機能を追加しましょう。

まず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が追加されたトップページ
図 28. フォローリクエストUIが追加されたトップページ

Followアクティビティの送信

フォローリクエスト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");
});
  1. lookupObject()関数は、​アクターを含むActivityPubオブジェクトを検索します。​入力としてActivityPubオブジェクトの一意のURIまたはフェディバースハンドルを受け取り、​検索したActivityPubオブジェクトを返します。

  2. isActor()関数は、​与えられたActivityPubオブジェクトがアクターかどうかを確認します。

  3. このコードではsendActivity()メソッドを使用して、​検索したアクターにFollowアクティビティを送信しています。​しかし、​まだfollowsテーブルにレコードは追加していません。​これは、​相手からAccept(Follow)アクティビティを受け取ってからレコードを追加する必要があるためです。

テスト

実装したフォローリクエスト機能が正しく動作するか確認する必要があります。​今回もアクティビティを送信する必要があるため、​fedify tunnelコマンドを使用してローカルサーバーをパブリックインターネットに公開した後、​ウェブブラウザでhttps://temp-address.serveo.net/(ドメイン名は置き換えてください)ページにアクセスします:

フォローリクエストUIがあるトップページ
図 29. フォローリクエストUIがあるトップページ

フォローリクエスト入力欄にフォローするアクターのフェディバースハンドルを入力する必要があります。​ここでは簡単なデバッグのためにActivityPub.Academyのアクターを入力しましょう。​参考までに、​ActivityPub.Academyにログインした一時アカウントのハンドルは、​一時アカウントの名前をクリックしてプロフィールページに入ると、​名前のすぐ下に表示されます:

ActivityPub.Academyのアカウントプロフィールページに表示されているフェディバースハンドル
図 30. ActivityPub.Academyのアカウントプロフィールページに表示されているフェディバースハンドル

以下のようにActivityPub.Academyのアクターハンドルを入力した後、​Followボタンをクリックしてフォローリクエストを送信します:

ActivityPub.Academyのアクターにフォローリクエストを送信中
図 31. ActivityPub.Academyのアクターにフォローリクエストを送信中

そしてActivityPub.AcademyのActivity Logを確認します:

ActivityPub.AcademyのActivity Log
図 32. ActivityPub.AcademyのActivity Log

Activity Logには我々が送信したFollowアクティビティと、​ActivityPub.Academyから送信された返信であるAccept(Follow)アクティビティが表示されます。

ActivityPub.Academyの通知ページに行くと、​実際にフォローリクエストが到着したことを確認できます:

ActivityPub.Academyの通知ページに表示された到着したフォローリクエスト
図 33. ActivityPub.Academyの通知ページに表示された到着したフォローリクエスト

Accept(Follow)アクティビティの受信

しかし、​まだ受信した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";
  1. このソースファイル内で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)アクティビティが発信されたのが確認できるはずです:

受信されたFollowアクティビティと発信されたAccept(Follow)アクティビティが表示されているActivity Log
図 34. 受信されたFollowアクティビティと発信されたAccept(Follow)アクティビティが表示されているActivity Log

まだフォローリストを実装していないため、​followsテーブルにレコードが正しく挿入されたか直接確認してみましょう:

$ echo "SELECT * FROM follows WHERE follower_id = 1;" | sqlite3 -table microblog.sqlite3

成功していれば、​以下のような結果が得られるはずです(following_id列の値は多少異なる可能性があります):

following_id follower_id created

3

1

2024-09-02 14:11:17

フォローリスト

我々が作成したアクターがフォローしているアクターのリストを表示するページを作成しましょう。

まず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ページを開いてみましょう:

フォローリスト
図 35. フォローリスト

フォロー数

フォロワー数を表示しているのと同様に、​フォロー数も表示する必要があります。

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> &middot;{" "}
        <a href={`/users/${username}/following`}>{following} following</a>{" "}
        &middot;{" "}
        <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ページを開いてみましょう:

プロフィールページ
図 36. プロフィールページ

タイムライン

多くの機能を実装しましたが、​まだ他のMastodonサーバーで書かれた投稿は表示されていません。​これまでの過程から推測できるように、​我々が投稿を作成したときにCreate(Note)アクティビティを送信したのと同様に、​他のサーバーからCreate(Note)アクティビティを受信する必要があります。

他のMastodonサーバーで投稿を作成したときに具体的に何が起こるかを見るために、​ActivityPub.Academyで新しい投稿を作成してみましょう:

ActivityPub.Academyで新しい投稿を作成中
図 37. ActivityPub.Academyで新しい投稿を作成中

Publish!ボタンをクリックして投稿を保存した後、​Activity Logページに移動してCreate(Note)アクティビティが確かに送信されたかどうか確認します:

送信されたCreate(Note)アクティビティが表示されているActivity Log
図 38. 送信されたCreate(Note)アクティビティが表示されている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);
  });
  1. getAttribution()メソッドを使用して投稿者を取得した後、​

  2. persistActor()関数を通じてそのアクターがまだactorsテーブルに存在しない場合は追加します。

  3. そして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

3

https://activitypub.academy/users/algusia_draneoll/statuses/113068684551948316

3

<p>Would it send a Create(Note) activity?</p>

https://activitypub.academy/@algusia_draneoll/113068684551948316

2024-09-02 15:33:32

リモート投稿の表示

さて、​これでリモート投稿を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)
  </>
);
  1. extends PostListPropsを追加

  2. <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/ページを開いてタイムラインを鑑賞しましょう:

トップページに表示されるタイムライン
図 39. トップページに表示されるタイムライン

上のように、​リモートで作成された投稿とローカルで作成された投稿が最新順に適切に表示されていることがわかります。​どうでしょうか?気に入りましたか?

このチュートリアルで実装する内容は以上です。​これを基に皆さん自身のマイクロブログを完成させることも可能でしょう。

改善点

このチュートリアルを通じて完成した皆さんのマイクロブログは、​残念ながらまだ実際の使用には適していません。​特にセキュリティ面で多くの脆弱性があるため、​実際に使用するのは危険かもしれません。

皆さんが作成したマイクロブログをさらに発展させたい方は、​以下の課題を自分で解決してみるのもよいでしょう:

  • 現在は認証が一切ないため、​誰でも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を開発。

ライセンス

本書はAsciiDocで組版されており、​以下のGitHubリポジトリからソースコードとPDF本を入手できます: