DenoとHonoでThreadsのContent-Type: application/activity+jsonをGETする
Intro
やつを作りました。
その名もThreads Proxy。
Denoを使いました。
Hono[炎]っていうイケてる名前のフレームワークを使って作りました。
どこかで見たことある流れですね。
そうです。以前Denoで作ったものをフォークしてコードを変えただけです。
ただ今回はMatchboxの原型がほとんどなく、徹底的に機能を削ぎ落とし、スクリプトファイル1つに詰め込みました。
Why
例えばMastodonで以下のようなコマンドを叩くとActivityPubで使われるJSON-LDが返ってきます。
$ curl -H "Accept: application/activity+json" https://mastodon.social/@Gargron
{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",...}
Misskeyでも同様です。
$ curl -H "Accept: application/activity+json" https://misskey.io/@syuilo
{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",...}
Threadsは違います。
$ curl -H "Accept: application/activity+json" https://www.threads.net/@mosseri
<!DOCTYPE html><html.../html>
なるほど。
HTMLページ用のURLとは別にActivityPub用のURLがあるんですね。
見つけてきたのでもう一度。
$ curl -H "Accept: application/activity+json" https://www.threads.net/ap/users/mosseri
{"success":false,"error":"Not found"}
???
このようにThreadsのActivityPubで使われるJSON-LDは普通の方法では取得できない仕組みをしており、調査した結果、サーバーを立てる必要があることが分かりました。
そこでMastodonやMisskeyとは比べ物にならないほど簡単に動かせるThreads用プロキシサーバーを作りました。
Run
Denoをインストールして秘密鍵id_rsaファイルがカレントディレクトリにあれば動きます。
必要に応じてid_rsa用の.gitignoreを追加しましょう。
$ curl -fsSL https://deno.land/install.sh | sh
$ export DENO_INSTALL="$HOME/.deno"
$ export PATH="$DENO_INSTALL/bin:$PATH"
$ echo 'id_rsa' > .gitignore
$ openssl genpkey -algorithm rsa -pkeyopt rsa_keygen_bits:4096 -out id_rsa
$ deno run --allow-read=id_rsa --allow-net --allow-env=HOSTS,PORT,PRIVATE_KEY https://tkithrta.gitlab.io/u/threads.ts https://www.example.com/users/alice
$ curl https://www.example.com/threads/mosseri
上記https://www.example.com
は例なので記載通りコマンド叩いても動きません。
各自ドメインやパスを指定してください。
私はGlitchで動かしていますが、hostnameやport, 秘密鍵を環境変数で渡せるようにしているのでDocker, CodeSandbox, Fly.io, Deno DeployなどなどDenoが動く環境であればどこでも動くと思います。
Threads
ところでThreadsとは何でしょうか?
かつてFacebookと呼ばれていたMetaがInstagramアカウントでログインできるTwitterのようなサービスとしてとして2023年7月5日頃に公開したSNSです。
オット! 今はXだったかな……フフ……
Metaverseに社運をかけて社名まで変えてしまうようなXのことを笑えないマーク・ザッカーバーグが2023年12月12日頃、突然Mastodonなどと接続できるActivityPubをサポートし、Threadsの投稿を他のサービスでも表示できるようにするテストを開始すると発表しました。
Fediverseに参加したりActivityPubに対応する話は前から聞いていましたが、あまりにも突然過ぎてFediverseに激震が走りました。
Source
では適当にThreads Proxyのソースコードを抜粋しながら紹介していきたいと思います。
今回の解説で不要なコードは省略していきますので、省略していないソースコードを確認したい場合はthreads.tsをダウンロードして開くかcurlで確認してください。
WindowsだとContent-Type: video/mp2t
として降ってくるのでコマンドプロンプトで確認したほうが早いです
$ curl https://tkithrta.gitlab.io/u/threads.ts
Deno
import { Hono } from "https://deno.land/x/hono@v4.0.10/mod.ts";
import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm.ts";
const $1 = new URL(Deno.args[0] || "about:blank");
...
let preferredUsername = ...
const privateKeyPem = Deno.env.get("PRIVATE_KEY") || await Deno.readTextFile("id_rsa");
const PRIVATE_KEY = ...
const PUBLIC_KEY = ...
const publicKeyPem = ...
const app = new Hono({ strict: false });
...
Deno.serve({
hostname: Deno.env.get("HOSTS") || "localhost",
port: Number(Deno.env.get("PORT")) || 8080,
}, app.fetch);
今回スクリプトファイル1つだけで全てが完結するようにしたかったのでDeno.args
を使い引数を渡せるようにしました。
MatchboxはBunで動かせるようにしていたり、他のActivityPub実装との兼ね合いもありBashスクリプトを別途書き設定を行えるようにしていたのですが、今回はDenoだけをターゲットにしているのでDenoで使える機能を使いまくっています。
とはいっても実際に使っているAPIはDeno.args
とDeno.env.get()
とDeno.serve()
ぐらいで、どちらかというとURLから直接スクリプトファイルを呼び出したり、Shebangを使い簡単に実行できるようになったのが使っていく上でとても重要な機能でした。
昨今のDenoは突然Jupyter Notebooksに対応したり、Deno Deployで力を発揮する機能やNode.jsとの互換性に力を入れており迷走している感が否めないのですが、Deno開発当初からライアン・ダールが考えていたDenoがLinuxコンテナそのもののように扱える機能は未だ他のランタイムにはない強力な機能だと感じました。
Hono
...
app.get("/", (c) => c.text("Hello, World!"));
...
app.get(`${$1.pathname}/inbox`, (c) => c.body(null, 405));
app.post(`${$1.pathname}/inbox`, (c) => c.body(null, 200));
app.post(`${$1.pathname}/outbox`, (c) => c.body(null, 405));
app.get(`${$1.pathname}/outbox`, (c) => c.body(null, 200));
...
Honoでいい感じにルーティングを生やしていきます。
実は上記ルーティングは今回の実装に不要なのですが、ルートパスが404返すのも変だし後述するActorではinbox, outbox必須だったのでとりあえず生やすか! と思い生やしたものです。
通常のActivityPub実装であればWebFingerやNodeInfoのパスも必要になるのですがThreadsのContent-Type: application/activity+json
をGETする際、Actor以外のパスにアクセスしてくることはないようです。
なので今回はなくしました。
Actor
Actorのルーティングです。
...
app.get($1.pathname, (c) => {
const body = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{ Key: "sec:Key" },
],
id: $1,
type: "Person",
inbox: `${$1}/inbox`,
outbox: `${$1}/outbox`,
preferredUsername,
url: $1,
publicKey: {
id: `${$1}#Key`,
type: "Key",
owner: $1,
publicKeyPem,
},
};
return c.json(body, 200, { "Content-Type": "application/activity+json" });
});
...
ThreadsのContent-Type: application/activity+json
をGETするにあたり、Actorが返す必要があるContent-Type: application/activity+json
はこれだけです。
JSON-LDで必須のidとtype、ActivityPubで必須のinboxとoutboxの他に、urlなどいくつか必須のプロパティがあるため注意が必要です。
最後にSecurity VocabularyのpublicKeyにPEM形式の公開鍵を置いておきましょう。非常に重要です。
DenoでpublicKeyPemを出力する処理だけで75行近くあるのですが、ここでは紹介しません。
ソースコードを読むか以下記事を参照してください。
Proxy
では今回メインとなるプロキシとしてふるまうルーティングをやっていきましょう。
...
app.get("/threads/:param", async (c) => {
const body = await getThreads(c.req.path);
return c.json(body, 200, { "Content-Type": "application/activity+json" });
});
app.get("/threads/:param/outbox", async (c) => {
const body = await getThreads(c.req.path);
return c.json(body, 200, { "Content-Type": "application/activity+json" });
});
app.get("/threads/:param/followers", async (c) => {
const body = await getThreads(c.req.path);
return c.json(body, 200, { "Content-Type": "application/activity+json" });
});
app.get("/threads/:param/following", async (c) => {
const body = await getThreads(c.req.path);
return c.json(body, 200, { "Content-Type": "application/activity+json" });
});
app.get("/threads/:param/post/:param", async (c) => {
const body = await getThreads(c.req.path);
return c.json(body, 200, { "Content-Type": "application/activity+json" });
});
app.get("/threads/:param/post/:param/activity", async (c) => {
const body = await getThreads(c.req.path);
return c.json(body, 200, { "Content-Type": "application/activity+json" });
});
...
中の処理は一緒ですが個別に生やしたなかなかワイルドなルーティングです。
現在のThreadsでは以下のパスにアクセスするとContent-Type: application/json
で404が帰ってきます。
$ curl -H "Accept: application/activity+json" https://www.threads.net/ap/users/0
$ curl -H "Accept: application/activity+json" https://www.threads.net/ap/users/0/outbox
$ curl -H "Accept: application/activity+json" https://www.threads.net/ap/users/0/followers
$ curl -H "Accept: application/activity+json" https://www.threads.net/ap/users/0/following
$ curl -H "Accept: application/activity+json" https://www.threads.net/ap/users/0/post/0
$ curl -H "Accept: application/activity+json" https://www.threads.net/ap/users/0/post/0/activity
また、少しでも上記パスが異なるとThreadsからContent-Type: text/html
で404 Not Foundが帰ってきます。
以前は上記パスでもThreadsからContent-Type: text/html
が返ってきていたので、JSONでパースできない非常に大きなBodyが返ってきて大変でした。
さて、404 Not Foundが返ってくるということは、ThreadsはActivityPubに対応していないのでしょうか。
実際にはそんなことなく、一部Threads開発者のアカウントでActivityPubのテストを行っており、MastodonやMisskeyでフォローできるようになっています。
その鍵を握るのはActivityPubでサーバーを相互に認証する仕組みで使われているHTTP Message Signaturesです。
getActivity
当初ActivityPubのHTTP Message Signaturesはhostとdateを認証するPOSTのみで使われてきました。
その後、POSTを受け取ったときBodyが改ざんされていないことを確認できるようDigest Headerも含まれるようになりました。
そしてついにはGETにも署名を行うようになりました。
ThreadsのContent-Type: application/activity+json
をGETするにはこの署名が必須なので、Threadsをフォローできないサーバーがあったりなかったりしたんですね。
AUTHORIZED_FETCHをtrueにするとMastodonもThreadsに近いふるまいをするようになっています。
GoToSocialも似たような仕組みを採用しています。
これらのサーバーと通信できないActivityPub実装が一部存在しており、例えば古いMisskeyだとconfig/default.ymlで設定できるsignToActivityPubGetがfalseだと通信できません。
以前はデフォルトがfalseでしたが、最近デフォルトでtrueになったので、Threadsと通信できない人はここの設定を見直してみるといいかもしれません。
そもそもThreadsは本来401 Unauthorizedを返すべき処理で404 Not Foundを返しているので頭にきますよ!!
何の話だっけ。
関数getActivity()
の解説をするつもりでした。
プロキシとして振る舞うルーティングで使われている、関数getThreads()
から呼び出しています。
async function getActivity(req: string) {
const utc = new Date().toUTCString();
const sig = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
PRIVATE_KEY,
stob(
[
`(request-target): get ${new URL(req).pathname}`,
`host: ${new URL(req).hostname}`,
`date: ${utc}`,
].join("\n"),
),
);
const b64 = btoa(btos(sig));
const headers = {
Host: new URL(req).hostname,
Date: utc,
Signature: [
`keyId="${$1}#Key"`,
'algorithm="rsa-sha256"',
'headers="(request-target) host date"',
`signature="${b64}"`,
].join(),
Accept: "application/activity+json",
"Accept-Encoding": "identity",
"Cache-Control": "no-cache",
"User-Agent": `Threads-Proxy/1.0.0 (+${$1.origin}/)`,
};
const res = await fetch(req, { method: "GET", headers });
return res.json();
}
このような処理を書けばGETに署名できます。
headers: From Proxy To Threads JSON Request
Accept: application/activity+json
Accept-Encoding: identity
Accept-Language: *
Cache-Control: no-cache
Connection: close
Date: Sun, 17 Dec 2023 12:00:00 GMT
Host: www.threads.net
Signature: keyId="https://www.example.com/users/alice#Key",algorithm="rsa-sha256",headers="(request-target) host date",signature="AA=="
User-Agent: Threads-Proxy/1.0.0 (+https://www.example.com/)
headers: From Threads To Alice Actor Request
Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
Accept-Encoding: deflate, gzip
Connection: close
Date: Sun, 17 Dec 2023 12:00:00 GMT
Host: www.example.com
Signature: keyId="https://www.threads.net/ap/users/threads.sys/#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="AA=="
User-Agent: facebookexternalua
署名を付与したHeadersをfetchで送れば、ActorにもHeadersに署名が付与されたThreadsからのアクセスが飛んできます。
このときAccept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
なので気をつけましょう。
Accept: application/activity+json
しか許可していない実装を作ってしまうと、ThreadsがpublicKeyPemを含んだActorのJSONをGETしてくれなくなります。
わたしです。
Sequence
以上、いくつか紹介してみましたが、よく分からないですよね。
私もよく分かっていません。
ちょうどZennはMermaidでシーケンス図を書けるので、これでまとめてみることにしました。
curlでアクセスすると署名がないので404 Not Foundが返ってきます。
もしThreads Proxyに署名をつける機能がなければ以下のように404 Not Foundが返ってきます。
Threads Proxyで署名をつけてあげると以下のようにContent-Type: application/activity+json
が返ってきます。
完全に理解した!!
Outro
今回はDenoとHonoでActivityPubとHTTP Message Signaturesを使い、ThreadsのContent-Type: application/activity+json
をGETするプロキシサーバーを作りました。
でもプロキシサーバーを作ったところでThreadsはフォローできません。
そこでThreads Proxyの元になったActivityPub実装Matchboxと、VercelとRaspberry Pi Zero WHで稼働実績のあるActivityPub実装PSHではThreadsをフォローしたりできるように改良しました。
元々GoToSocial用のgetActivity()
はThreadsがActivityPubをサポートする前から実装していたので、ActorでAccept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"
を通すように修正するだけで動きました。
今まではAccept: application/activity+json
しか通していなかったので。
Threadsについて賛否両論あるようですが、1億人のユーザーが集まるSNSと接続できるSNSをひとりで開発、運用、保守するのは非常によいモチベーションになります。
今回の記事をきっかけにActivityPubやFediverseを支える技術に興味を持っていただけたら幸いです。
最後までお読みいただきありがとうございました。
Discussion