🎁

DenoとHonoでThreadsのContent-Type: application/activity+jsonをGETする

2023/12/19に公開

Intro

やつを作りました。

その名もThreads Proxy。

https://tkithrta.gitlab.io/u/threads.ts

Denoを使いました。

https://deno.land/

Hono[炎]っていうイケてる名前のフレームワークを使って作りました。

https://hono.dev/

どこかで見たことある流れですね。

https://gitlab.com/acefed/matchbox

そうです。以前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

私はGlitchで動かしていますが、hostnameやport, 秘密鍵を環境変数で渡せるようにしているのでDocker, CodeSandbox, Fly.io, Deno DeployなどなどDenoが動く環境であればどこでも動くと思います。

Threads

ところでThreadsとは何でしょうか?

https://www.threads.net/

かつてFacebookと呼ばれていたMetaがInstagramアカウントでログインできるTwitterのようなサービスとしてとして2023年7月5日頃に公開したSNSです。

https://www.itmedia.co.jp/news/articles/2307/06/news121.html

オット! 今はXだったかな……フフ……

https://www.itmedia.co.jp/news/articles/2312/14/news099.html

Metaverseに社運をかけて社名まで変えてしまうようなXのことを笑えないマーク・ザッカーバーグが2023年12月12日頃、突然Mastodonなどと接続できるActivityPubをサポートし、Threadsの投稿を他のサービスでも表示できるようにするテストを開始すると発表しました。

https://www.itmedia.co.jp/news/articles/2312/17/news052.html

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.argsDeno.env.get()Deno.serve()ぐらいで、どちらかというとURLから直接スクリプトファイルを呼び出したり、Shebangを使い簡単に実行できるようになったのが使っていく上でとても重要な機能でした。

昨今のDenoは突然Jupyter Notebooksに対応したり、Deno Deployで力を発揮する機能やNode.jsとの互換性に力を入れており迷走している感が否めないのですが、Deno開発当初からライアン・ダールが考えていたDenoがLinuxコンテナそのもののように扱える機能は未だ他のランタイムにはない強力な機能だと感じました。

https://tinyclouds.org/javascript_containers

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行近くあるのですが、ここでは紹介しません。
ソースコードを読むか以下記事を参照してください。

https://zenn.dev/tkithrta/articles/d5865b67d18d9c

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です。

https://docs.joinmastodon.org/spec/security/
https://tools.ietf.org/html/draft-cavage-http-signatures-06
https://httpwg.org/http-extensions/draft-ietf-httpbis-message-signatures.html

getActivity

当初ActivityPubのHTTP Message Signaturesはhostとdateを認証するPOSTのみで使われてきました。

https://github.com/mastodon/mastodon/pull/4146

その後、POSTを受け取ったときBodyが改ざんされていないことを確認できるようDigest Headerも含まれるようになりました。

https://github.com/mastodon/mastodon/pull/4565

そしてついにはGETにも署名を行うようになりました。

https://github.com/mastodon/mastodon/pull/11269

ThreadsのContent-Type: application/activity+jsonをGETするにはこの署名が必須なので、Threadsをフォローできないサーバーがあったりなかったりしたんですね。

AUTHORIZED_FETCHをtrueにするとMastodonもThreadsに近いふるまいをするようになっています。

GoToSocialも似たような仕組みを採用しています。

https://docs.gotosocial.org/en/latest/federation/federating_with_gotosocial/

これらのサーバーと通信できないActivityPub実装が一部存在しており、例えば古いMisskeyだとconfig/default.ymlで設定できるsignToActivityPubGetがfalseだと通信できません。

https://github.com/misskey-dev/misskey/pull/6731

以前はデフォルトがfalseでしたが、最近デフォルトでtrueになったので、Threadsと通信できない人はここの設定を見直してみるといいかもしれません。

https://github.com/misskey-dev/misskey/issues/9376

そもそも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をフォローしたりできるように改良しました。

https://gitlab.com/acefed/matchbox
https://gitlab.com/tkithrta/psh

元々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