🤮

DenoとHonoでActivityPub実装を作ったので紹介だけします。紹介だけ

2022/07/21に公開

その名もMatchbox。

https://gitlab.com/acefed/matchbox

Denoを使いました。

https://deno.land/

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

https://hono.dev/

実はデスクトップ上にて取り急ぎzenn.txtみたいなファイルを作って超長い解説記事を書いていたのですが、誤ってzenn.txtを完全に削除してしまったので後悔しないように紹介だけします。
数行誤って文章を消したり古い文章に巻き戻ったりしたことは今まで何回か経験ありますが、不注意で未公開の記事まるごと1つ消したのは生まれて初めてです。

タスクが大量にあっても寝ないで作業を続けないほうがいいです。

とてもいいアイデアですね。
おわり。
ちょっと横になるわ。


2022/07/21 21:00追記

おはようございます。

どうもすみません。なんか大分不貞腐れた300文字程度の記事にそこそこいいねついててびっくりしました。
一応HonoというタグつけていたのでHonoの作者の方に見つかるだろうと少し思ってはいたのですが、こういった予想はいつも外して生きてきたのでついやっちゃいました。yusukebeさん確認ありがとうございます。
リコリス・リコイル見たらめっちゃ元気になりました。リコリス・リコイルはいいぞ。

改めて読むと本当に無の記事だなと思うので若干Tech要素ではありますが、

  • 開発するにあたって参考にしたドキュメントと記事
  • Matchboxの元となったStrawberryFields ExpressとMatchboxのdiff
    • StrawberryFields Express 2.1.0
      • Node.js 14.20.0
      • Express 4.18.1
      • ES2020
    • Matchbox 0.1.0
      • Deno 1.23.4
      • Hono 2.0.2
      • TypeScript 4.7.4

を残しておきます。各内容に関して解説する余力は残っていませんが何かの一助になれば幸いです。
なお、ソースコードで重複する部分は...と記載し省略してあります。

https://zenn.dev/tkithrta/articles/d5865b67d18d9c
https://denoflare.dev/
https://minipub.dev/
https://zenn.dev/yusukebe/articles/0c7fed0949e6f7
https://zenn.dev/yusukebe/articles/47dea431a00752
https://marmooo.blogspot.com/2022/07/node-deno-bun.html

Matchboxは元々Deno.sqliteバインディングが1.24に追加されると聞いて取り急ぎ作ったのですが残念ながら実装されませんでした😢

https://github.com/denoland/deno/issues/14460
https://github.com/denoland/deno/pull/14627
https://github.com/denoland/deno/issues/11657

追記:……と思ったら復活しました😮

$ curl -O https://gitlab.com/acefed/strawberryfields-express/-/raw/master/index.js
$ curl -O https://gitlab.com/acefed/matchbox/-/raw/main/index.ts
$ diff -u0 index.js index.ts
--- index.js	2022-07-21 11:30:00.000000000 +0000
+++ index.ts	2022-07-21 11:30:00.000000000 +0000
@@ -1,9 +1,9 @@
-const crypto = require("crypto");
-const express = require("express");
-const axios = require("axios");
-
-require("dotenv").config();
-const app = express();
-app.use("/public", express.static("public"));
-app.use(express.json({ type: "application/activity+json" }));
-app.set("trust proxy", true);
+import { crypto } from "https://deno.land/std@0.148.0/crypto/mod.ts";
+import "https://deno.land/std@0.148.0/dotenv/load.ts";
+import { serve } from "https://deno.land/std@0.148.0/http/server.ts";
+import { Hono } from "https://deno.land/x/hono@v2.0.2/mod.ts";
+import { serveStatic } from "https://deno.land/x/hono@v2.0.2/middleware.ts";
+
+const app = new Hono();
+app.use("/public/*", serveStatic());
+app.onError((_err, c) => c.body(null, 500));
@@ -12,2 +12,68 @@
-const PRIVATE_KEY = JSON.parse(`"${process.env.PRIVATE_KEY}"`);
-const PUBLIC_KEY = crypto.createPublicKey(PRIVATE_KEY).export({ type: "spki", format: "pem" });
+const PRIVATE_KEY = await readPrivateKey(String(Deno.env.get("PRIVATE_KEY")));
+const PUBLIC_KEY = await privateKeyToPublicKey(PRIVATE_KEY);
+const PUBLIC_KEY_PEM = await writePublicKey(PUBLIC_KEY);
+
+function stringToArrayBuffer(s: string) {
+  return Uint8Array.from(s, (c) => c.charCodeAt(0));
+}
+
+function arrayBufferToString(b: ArrayBuffer) {
+  return String.fromCharCode(...new Uint8Array(b));
+}
+
+async function readPrivateKey(pem: string) {
+  const pemHeader = "-----BEGIN PRIVATE KEY-----";
+  const pemFooter = "-----END PRIVATE KEY-----";
+  if (pem.startsWith('"')) pem = pem.slice(1);
+  if (pem.endsWith('"')) pem = pem.slice(0, -1);
+  pem = pem.split("\\n").join("");
+  pem = pem.split("\n").join("");
+  const pemContents = pem.substring(pemHeader.length, pem.length - pemFooter.length);
+  const der = stringToArrayBuffer(atob(pemContents));
+  const r = await crypto.subtle.importKey(
+    "pkcs8",
+    der,
+    {
+      name: "RSASSA-PKCS1-v1_5",
+      hash: "SHA-256",
+    },
+    true,
+    ["sign"],
+  );
+  return r;
+}
+
+async function privateKeyToPublicKey(key: CryptoKey) {
+  const jwk = await crypto.subtle.exportKey("jwk", key);
+  delete jwk.d;
+  delete jwk.p;
+  delete jwk.q;
+  delete jwk.dp;
+  delete jwk.dq;
+  delete jwk.qi;
+  delete jwk.oth;
+  jwk.key_ops = ["verify"];
+  const r = await crypto.subtle.importKey(
+    "jwk",
+    jwk,
+    {
+      name: "RSASSA-PKCS1-v1_5",
+      hash: "SHA-256",
+    },
+    true,
+    ["verify"],
+  );
+  return r;
+}
+
+async function writePublicKey(key: CryptoKey) {
+  const der = await crypto.subtle.exportKey("spki", key);
+  let pemContents = btoa(arrayBufferToString(der));
+  let pem = "-----BEGIN PUBLIC KEY-----\n";
+  while (pemContents.length > 0) {
+    pem += pemContents.substring(0, 64) + "\n";
+    pemContents = pemContents.substring(64);
+  }
+  pem += "-----END PUBLIC KEY-----\n";
+  return pem;
+}
@@ -15 +81 @@
-function talkScript(req) {
+function talkScript(req: string) {
@@ -19 +85 @@
-async function getInbox(req) {
+async function getInbox(req: string) {
@@ -21,2 +87,5 @@
-  const res = await axios.get(req, { headers: { Accept: "application/activity+json" } });
-  return res.data;
+  const res = await fetch(req, {
+    method: "GET",
+    headers: { Accept: "application/activity+json" },
+  });
+  return res.json();
@@ -25 +94 @@
-async function postInbox(req, data, headers) {
+async function postInbox(req: string, data: any, headers: any) {
@@ -27 +96 @@
-  await axios.post(req, JSON.stringify(data), { headers: headers });
+  await fetch(req, { method: "POST", body: JSON.stringify(data), headers: headers });
@@ -30 +99 @@
-function signHeaders(res, strName, strHost, strInbox) {
+async function signHeaders(res: any, strName: string, strHost: string, strInbox: string) {
@@ -32,4 +101,6 @@
-  const s256 = crypto.createHash("sha256").update(JSON.stringify(res)).digest("base64");
-  const sig = crypto
-    .createSign("sha256")
-    .update(
+  const s = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(res)));
+  const s256 = btoa(arrayBufferToString(s));
+  const sig = await crypto.subtle.sign(
+    "RSASSA-PKCS1-v1_5",
+    PRIVATE_KEY,
+    stringToArrayBuffer(
@@ -39,4 +110,4 @@
-        `digest: SHA-256=${s256}`
-    )
-    .end();
-  const b64 = sig.sign(PRIVATE_KEY, "base64");
+        `digest: SHA-256=${s256}`,
+    ),
+  );
+  const b64 = btoa(arrayBufferToString(sig));
@@ -47,2 +118 @@
-    Signature:
-      `keyId="https://${strHost}/u/${strName}",` +
+    Signature: `keyId="https://${strHost}/u/${strName}",` +
@@ -55 +125 @@
-    "User-Agent": `StrawberryFields-Express/2.1.0 (+https://${strHost}/)`,
+    "User-Agent": `Matchbox/0.1.0 (+https://${strHost}/)`,
@@ -60,2 +130,2 @@
-async function acceptFollow(strName, strHost, x, y) {
-  const numId = Math.floor(Date.now() / 1000);
+async function acceptFollow(strName: string, strHost: string, x: any, y: any) {
+  const numId = crypto.randomUUID();
@@ -70 +140 @@
-  const headers = signHeaders(res, strName, strHost, strInbox);
+  const headers = await signHeaders(res, strName, strHost, strInbox);
@@ -74,2 +144,2 @@
-async function follow(strName, strHost, x) {
-  ...
+async function follow(strName: string, strHost: string, x: any) {
+  ...
@@ -84 +154 @@
-  ...
+  ...
@@ -88,2 +158,2 @@
-async function undoFollow(strName, strHost, x) {
-  ...
+async function undoFollow(strName: string, strHost: string, x: any) {
+  ...
@@ -101 +171 @@
-  ...
+  ...
@@ -105,2 +175,2 @@
-async function like(strName, strHost, x, y) {
-  ...
+async function like(strName: string, strHost: string, x: any, y: any) {
+  ...
@@ -115 +185 @@
-  ...
+  ...
@@ -119,2 +189,2 @@
-async function undoLike(strName, strHost, x, y) {
-  ...
+async function undoLike(strName: string, strHost: string, x: any, y: any) {
+  ...
@@ -132 +202 @@
-  ...
+  ...
@@ -136,2 +206,2 @@
-async function announce(strName, strHost, x, y) {
-  ...
+async function announce(strName: string, strHost: string, x: any, y: any) {
+  ...
@@ -150 +220 @@
-  ...
+  ...
@@ -154,2 +224,2 @@
-async function undoAnnounce(strName, strHost, x, y) {
-  ...
+async function undoAnnounce(strName: string, strHost: string, x: any, y: any) {
+  ...
@@ -167 +237 @@
-  ...
+  ...
@@ -171,2 +241,2 @@
-async function createNote(strName, strHost, x, y) {
-  ...
+async function createNote(strName: string, strHost: string, x: any, y: string) {
+  ...
@@ -194 +264 @@
-  ...
+  ...
@@ -198,2 +268,2 @@
-async function createNoteMention(strName, strHost, x, y, z) {
-  ...
+async function createNoteMention(strName: string, strHost: string, x: any, y: any, z: string) {
+  ...
@@ -228 +298 @@
-  ...
+  ...
@@ -232,2 +302,2 @@
-async function createNoteHashtag(strName, strHost, x, y, z) {
-  ...
+async function createNoteHashtag(strName: string, strHost: string, x: any, y: string, z: string) {
+  ...
@@ -261 +331 @@
-  ...
+  ...
@@ -265,2 +335,2 @@
-async function deleteNote(strName, strHost, x, y) {
-  ...
+async function deleteNote(strName: string, strHost: string, x: any, y: string) {
+  ...
@@ -278 +348 @@
-  ...
+  ...
@@ -282 +352 @@
-app.get("/", (_req, res) => res.type("text/plain").send("StrawberryFields Express"));
+app.get("/", (c) => c.text("Matchbox: ActivityPub@Hono"));
@@ -284,6 +354,6 @@
-app.get("/u/:strName", (req, res) => {
-  const strName = req.params.strName;
-  const strHost = req.hostname;
-  if (strName !== CONFIG.preferredUsername) return res.sendStatus(404);
-  if (!req.header("Accept").includes("application/activity+json")) {
-    return res.type("text/plain").send(`${strName}: ${CONFIG.name}`);
+app.get("/u/:strName", (c) => {
+  const strName = c.req.param("strName");
+  const strHost = new URL(c.req.url).hostname;
+  if (strName !== CONFIG.preferredUsername) return c.notFound();
+  if (!c.req.header("Accept").includes("application/activity+json")) {
+    return c.text(`${strName}: ${CONFIG.name}`);
@@ -301 +371 @@
-    summary: `<p>2.1.0</p>`,
+    summary: `<p>0.1.0</p>`,
@@ -307 +377 @@
-      publicKeyPem: PUBLIC_KEY,
+      publicKeyPem: PUBLIC_KEY_PEM,
@@ -320 +390 @@
-  res.type("application/activity+json").json(r);
+  return c.json(r, 200, { "Content-Type": "activity+json" });
@@ -323,8 +393,9 @@
-app.get("/u/:strName/inbox", async (_req, res) => res.sendStatus(405));
-app.post("/u/:strName/inbox", async (req, res) => {
-  const strName = req.params.strName;
-  const strHost = req.hostname;
-  if (strName !== CONFIG.preferredUsername) return res.sendStatus(404);
-  if (!req.header("Content-Type").includes("application/activity+json")) return res.sendStatus(400);
-  const y = req.body;
-  if (new URL(y.actor).protocol !== "https:") return res.sendStatus(400);
+app.get("/u/:strName/inbox", (c) => c.body(null, 405));
+app.post("/u/:strName/inbox", async (c) => {
+  const strName = c.req.param("strName");
+  const strHost = new URL(c.req.url).hostname;
+  if (strName !== CONFIG.preferredUsername) return c.notFound();
+  if (!c.req.header("Content-Type").includes("application/activity+json")) return c.body(null, 400);
+  const b = await c.req.parseBody();
+  const y = JSON.parse(new TextDecoder().decode(b));
+  if (new URL(y.actor).protocol !== "https:") return c.body(null, 400);
@@ -333 +404 @@
-  if (!x) return res.sendStatus(500);
+  if (!x) return c.body(null, 500);
@@ -336 +407 @@
-    return res.status().end();
+    return c.body(null);
@@ -338 +409 @@
-  if (y.type === "Like" || y.type === "Announce") return res.status().end();
+  if (y.type === "Like" || y.type === "Announce") return c.body(null);
@@ -343 +414 @@
-      ...
+      ...
@@ -345 +416 @@
-    if (z.type === "Like" || z.type === "Announce") return res.status().end();
+    if (z.type === "Like" || z.type === "Announce") return c.body(null);
@@ -347,3 +418,3 @@
-  if (y.type === "Accept" || y.type === "Reject") return res.status().end();
-  if (y.type === "Create" || y.type === "Update" || y.type === "Delete") return res.status().end();
-  res.sendStatus(500);
+  if (y.type === "Accept" || y.type === "Reject") return c.body(null);
+  if (y.type === "Create" || y.type === "Update" || y.type === "Delete") return c.body(null);
+  return c.body(null, 500);
@@ -352,6 +423,6 @@
-app.post("/u/:strName/outbox", (_req, res) => res.sendStatus(405));
-app.get("/u/:strName/outbox", (req, res) => {
-  const strName = req.params.strName;
-  const strHost = req.hostname;
-  if (strName !== CONFIG.preferredUsername) return res.sendStatus(404);
-  if (!req.header("Accept").includes("application/activity+json")) return res.sendStatus(400);
+app.post("/u/:strName/outbox", (c) => c.body(null, 405));
+app.get("/u/:strName/outbox", (c) => {
+  const strName = c.req.param("strName");
+  const strHost = new URL(c.req.url).hostname;
+  if (strName !== CONFIG.preferredUsername) return c.notFound();
+  if (!c.req.header("Accept").includes("application/activity+json")) return c.body(null, 400);
@@ -364 +435 @@
-  res.type("application/activity+json").json(r);
+  return c.json(r, 200, { "Content-Type": "activity+json" });
@@ -367,5 +438,5 @@
-app.get("/u/:strName/following", (req, res) => {
-  ...
-  ...
-  ...
-  ...
+app.get("/u/:strName/following", (c) => {
+  ...
+  ...
+  ...
+  ...
@@ -378 +449 @@
-  ...
+  ...
@@ -381,5 +452,5 @@
-app.get("/u/:strName/followers", (req, res) => {
-  ...
-  ...
-  ...
-  ...
+app.get("/u/:strName/followers", (c) => {
+  ...
+  ...
+  ...
+  ...
@@ -392 +463 @@
-  ...
+  ...
@@ -395,11 +466,11 @@
-app.post("/s/:strSecret/u/:strName", async (req, res) => {
-  const strName = req.params.strName;
-  const strHost = req.hostname;
-  if (strName !== CONFIG.preferredUsername) return res.sendStatus(404);
-  if (!req.params.strSecret || req.params.strSecret === "-") return res.sendStatus(404);
-  if (req.params.strSecret !== process.env.SECRET) return res.sendStatus(404);
-  if (!req.query.id || !req.query.type) return res.sendStatus(400);
-  if (new URL(req.query.id).protocol !== "https:") return res.sendStatus(400);
-  const x = await getInbox(req.query.id);
-  if (!x) return res.sendStatus(500);
-  const t = req.query.type;
+app.post("/s/:strSecret/u/:strName", async (c) => {
+  const strName = c.req.param("strName");
+  const strHost = new URL(c.req.url).hostname;
+  if (strName !== CONFIG.preferredUsername) return c.notFound();
+  if (!c.req.param("strSecret") || c.req.param("strSecret") === "-") return c.notFound();
+  if (c.req.param("strSecret") !== Deno.env.get("SECRET")) return c.notFound();
+  if (!c.req.query("id") || !c.req.query("type")) return c.body(null, 400);
+  if (new URL(c.req.query("id")).protocol !== "https:") return c.body(null, 400);
+  const x = await getInbox(c.req.query("id"));
+  if (!x) return c.body(null, 500);
+  const t = c.req.query("type");
@@ -408 +479 @@
-    return res.status().end();
+    return c.body(null);
@@ -412 +483 @@
-    ...
+    ...
@@ -416 +487 @@
-    ...
+    ...
@@ -420 +491 @@
-    if (!y) return res.sendStatus(500);
+    if (!y) return c.body(null, 500);
@@ -422 +493 @@
-    ...
+    ...
@@ -426 +497 @@
-    ...
+    ...
@@ -428 +499 @@
-    ...
+    ...
@@ -432 +503 @@
-    ...
+    ...
@@ -434 +505 @@
-    ...
+    ...
@@ -438 +509 @@
-    ...
+    ...
@@ -440 +511 @@
-    ...
+    ...
@@ -443,2 +514,2 @@
-    const y = req.query.url;
-    if (new URL(y).protocol !== "https:") return res.sendStatus(400);
+    const y = c.req.query("url");
+    if (new URL(y).protocol !== "https:") return c.body(null, 400);
@@ -446 +517 @@
-    ...
+    ...
@@ -450,3 +521,3 @@
-    if (!y) return res.sendStatus(500);
-    const z = req.query.url;
-    if (new URL(z).protocol !== "https:") return res.sendStatus(400);
+    if (!y) return c.body(null, 500);
+    const z = c.req.query("url");
+    if (new URL(z).protocol !== "https:") return c.body(null, 400);
@@ -454 +525 @@
-    ...
+    ...
@@ -457,3 +528,3 @@
-    const y = req.query.url;
-    if (new URL(y).protocol !== "https:") return res.sendStatus(400);
-    const z = req.query.tag;
+    const y = c.req.query("url");
+    if (new URL(y).protocol !== "https:") return c.body(null, 400);
+    const z = c.req.query("tag");
@@ -461 +532 @@
-    ...
+    ...
@@ -464,2 +535,2 @@
-    const y = req.query.url;
-    if (new URL(y).protocol !== "https:") return res.sendStatus(400);
+    const y = c.req.query("url");
+    if (new URL(y).protocol !== "https:") return c.body(null, 400);
@@ -467 +538 @@
-    ...
+    ...
@@ -469 +540 @@
-  res.sendStatus(500);
+  return c.body(null, 500);
@@ -472 +543 @@
-app.get("/.well-known/webfinger", (req, res) => {
+app.get("/.well-known/webfinger", (c) => {
@@ -474,2 +545,2 @@
-  const strHost = req.hostname;
-  if (req.query.resource !== `acct:${strName}@${strHost}`) return res.sendStatus(404);
+  const strHost = new URL(c.req.url).hostname;
+  if (c.req.query("resource") !== `acct:${strName}@${strHost}`) return c.notFound();
@@ -492 +563 @@
-  res.type("application/jrd+json").json(r);
+  return c.json(r, 200, { "Content-Type": "jrd+json" });
@@ -495,8 +566,11 @@
-app.get("/@", (_req, res) => res.redirect("/"));
-app.get("/u", (_req, res) => res.redirect("/"));
-app.get("/user", (_req, res) => res.redirect("/"));
-app.get("/users", (_req, res) => res.redirect("/"));
-
-app.get("/users/:strName", (req, res) => res.redirect(`/u/${req.params.strName}`));
-app.get("/user/:strName", (req, res) => res.redirect(`/u/${req.params.strName}`));
-app.get("/@:strName", (req, res) => res.redirect(`/u/${req.params.strName}`));
+app.get("/@", (c) => c.redirect("/"));
+app.get("/u", (c) => c.redirect("/"));
+app.get("/user", (c) => c.redirect("/"));
+app.get("/users", (c) => c.redirect("/"));
+
+app.get("/users/:strName", (c) => c.redirect(`/u/${c.req.param("strName")}`));
+app.get("/user/:strName", (c) => c.redirect(`/u/${c.req.param("strName")}`));
+app.get("/:strRoot", (c) => {
+  if (!c.req.param("strRoot").startsWith("@")) return c.notFound();
+  return c.redirect(`/u/${c.req.param("strRoot").slice(1)}`);
+});
@@ -504 +578,4 @@
-app.listen(process.env.PORT || 8080);
+serve((r) => app.fetch(r), {
+  hostname: Deno.env.get("HOST") || "localhost",
+  port: Number(Deno.env.get("PORT")) || 8000,
+});

Discussion