Unverified Commit 843b0bc8 authored by Hong Minhee's avatar Hong Minhee
Browse files

fedify inbox command

parent 7dc1eb38
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -24,6 +24,13 @@
      "source.sortImports": "always"
    }
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "denoland.vscode-deno",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.sortImports": "always"
    }
  },
  "cSpell.words": [
    "codegen",
    "Deno",
@@ -31,11 +38,14 @@
    "docloader",
    "fedify",
    "fediverse",
    "highligher",
    "hongminhee",
    "hono",
    "httpsig",
    "logtape",
    "nodeinfo",
    "poppanator",
    "shiki",
    "tempserver",
    "webfinger"
  ]
+4 −0
Original line number Diff line number Diff line
@@ -3,6 +3,10 @@
  "version": "0.8.0",
  "exports": "./mod.ts",
  "importMap": "import_map.g.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx"
  },
  "exclude": [
    ".vscode",
    "scripts"
+7 −1
Original line number Diff line number Diff line
@@ -37,12 +37,18 @@
    "mock_fetch": "https://deno.land/x/mock_fetch@0.3.0/mod.ts",
    "uri-template-router": "npm:uri-template-router@^0.0.16",
    "url-template": "npm:url-template@^3.1.1",
    "@cliffy/ansi": "jsr:@cliffy/ansi@1.0.0-rc.4",
    "@cliffy/command": "jsr:@cliffy/command@1.0.0-rc.4",
    "@cliffy/prompt": "jsr:@cliffy/prompt@1.0.0-rc.4",
    "@cliffy/table": "jsr:@cliffy/table@1.0.0-rc.4",
    "@cross/dir": "jsr:@cross/dir@^1.1.0",
    "@hongminhee/localtunnel": "jsr:@hongminhee/localtunnel@^0.1.1",
    "@poppanator/http-constants": "npm:@poppanator/http-constants@^1.1.1",
    "cli-highlight": "npm:cli-highlight@^2.1.11",
    "hono": "npm:hono@^4.2.8",
    "hono/": "npm:/hono@^4.2.8/",
    "json-preserve-indent": "npm:json-preserve-indent@^1.1.3",
    "ora": "npm:ora@^8.0.1"
    "ora": "npm:ora@^8.0.1",
    "shiki": "npm:shiki@^1.3.0"
  }
}
+7 −1
Original line number Diff line number Diff line
{
  "imports": {
    "@cliffy/ansi": "jsr:@cliffy/ansi@1.0.0-rc.4",
    "@cliffy/command": "jsr:@cliffy/command@1.0.0-rc.4",
    "@cliffy/prompt": "jsr:@cliffy/prompt@1.0.0-rc.4",
    "@cliffy/table": "jsr:@cliffy/table@1.0.0-rc.4",
    "@cross/dir": "jsr:@cross/dir@^1.1.0",
    "@hongminhee/localtunnel": "jsr:@hongminhee/localtunnel@^0.1.1",
    "@logtape/logtape": "jsr:@logtape/logtape@^0.2.2",
    "@poppanator/http-constants": "npm:@poppanator/http-constants@^1.1.1",
    "@std/fs": "jsr:@std/fs@^0.220.1",
    "@std/path": "jsr:@std/path@^0.220.1",
    "cli-highlight": "npm:cli-highlight@^2.1.11",
    "hono": "npm:hono@^4.2.8",
    "hono/": "npm:/hono@^4.2.8/",
    "json-preserve-indent": "npm:json-preserve-indent@^1.1.3",
    "ora": "npm:ora@^8.0.1"
    "ora": "npm:ora@^8.0.1",
    "shiki": "npm:shiki@^1.3.0"
  }
}

cli/inbox.tsx

0 → 100644
+290 −0
Original line number Diff line number Diff line
import { colors } from "@cliffy/ansi";
import { Command } from "@cliffy/command";
import { Cell, Table } from "@cliffy/table";
import {
  Accept,
  Activity,
  type Actor,
  Application,
  type Context,
  Endpoints,
  Federation,
  Follow,
  generateCryptoKeyPair,
  getActorHandle,
  Image,
  InProcessMessageQueue,
  isActor,
  lookupObject,
  MemoryKvStore,
} from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";
import { Hono } from "hono";
import ora from "ora";
import { getDocumentLoader } from "./docloader.ts";
import type { ActivityEntry } from "./inbox/entry.ts";
import { ActivityEntryPage, ActivityListPage } from "./inbox/view.tsx";
import { recordingSink } from "./log.ts";
import { tableStyle } from "./table.ts";
import { spawnTemporaryServer } from "./tempserver.ts";

const logger = getLogger(["fedify", "cli", "inbox"]);

export const command = new Command()
  .description(
    "Spins up an ephemeral ActivityPub server and receives activities.  " +
      "You can monitor the incoming activities in real-time.",
  )
  .option(
    "-f, --follow=<uri:string>",
    "Follow the given actor.  The argument can be either an actor URI or " +
      "a handle.  Can be specified multiple times.",
    { collect: true },
  )
  .option(
    "-a, --accept-follow=<uri:string>",
    "Accept follow requests from the given actor.  The argument can be " +
      "either an actor URI or a handle, or a wildcard (*).  Can be " +
      "specified multiple times.  If a wildcard is specified, all follow " +
      "requests will be accepted.",
    { collect: true },
  )
  .action(async (options) => {
    const spinner = ora({
      text: "Spinning up an ephemeral ActivityPub server...",
      discardStdin: false,
    }).start();
    const server = await spawnTemporaryServer(fetch);
    spinner.succeed(
      `The ephemeral ActivityPub server is up and running: ${
        colors.green(server.url.href)
      }`,
    );
    spinner.start();
    const fedCtx = federation.createContext(server.url, -1);
    if (options.follow != null && options.follow.length > 0) {
      spinner.text = "Following actors...";
      const documentLoader = await fedCtx.getDocumentLoader({ handle: "i" });
      acceptFollows.push(...(options.acceptFollow ?? []));
      for (const uri of options.follow) {
        spinner.text = `Following ${colors.green(uri)}...`;
        const actor = await lookupObject(uri, { documentLoader });
        if (!isActor(actor)) {
          spinner.fail(`Not an actor: ${colors.red(uri)}`);
          spinner.start();
          continue;
        }
        await fedCtx.sendActivity(
          { handle: "i" },
          actor,
          new Follow({
            actor: fedCtx.getActorUri("i"),
            object: actor.id,
          }),
        );
        spinner.succeed(`Followed ${colors.green(uri)}`);
        spinner.start();
      }
    }
    spinner.stop();
    printServerInfo(fedCtx);
  });

const federation = new Federation<number>({
  kv: new MemoryKvStore(),
  queue: new InProcessMessageQueue(),
  treatHttps: true,
  documentLoader: await getDocumentLoader(),
});

const time = Temporal.Now.instant();
let actorKeyPair: CryptoKeyPair | undefined = undefined;

federation
  .setActorDispatcher("/{handle}", (ctx, handle, key) => {
    if (handle !== "i") return null;
    return new Application({
      id: ctx.getActorUri(handle),
      preferredUsername: handle,
      name: "Fedify Ephemeral Inbox",
      inbox: ctx.getInboxUri(handle),
      endpoints: new Endpoints({
        sharedInbox: ctx.getInboxUri(),
      }),
      published: time,
      icon: new Image({
        url: new URL("https://fedify.dev/logo.png"),
        mediaType: "image/png",
      }),
      publicKey: key,
    });
  })
  .setKeyPairDispatcher(async (_ctxData, handle) => {
    if (handle !== "i") return null;
    if (actorKeyPair == null) actorKeyPair = await generateCryptoKeyPair();
    return actorKeyPair;
  });

const activities: ActivityEntry[] = [];

const acceptFollows: string[] = [];

async function acceptsFollowFrom(actor: Actor): Promise<boolean> {
  const actorUri = actor.id;
  let actorHandle: string | undefined = undefined;
  if (actorUri == null) return false;
  for (let uri of acceptFollows) {
    if (uri === "*") return true;
    if (uri.startsWith("http:") || uri.startsWith("https:")) {
      uri = new URL(uri).href; // normalize
      if (uri === actorUri.href) return true;
    }
    if (actorHandle == null) actorHandle = await getActorHandle(actor);
    if (actorHandle === uri) return true;
  }
  return false;
}

federation
  .setInboxListeners("/{handle}/inbox", "/inbox")
  .on(Activity, async (ctx, activity) => {
    activities[ctx.data].activity = activity;
    if (activity instanceof Follow) {
      if (acceptFollows.length < 1) return;
      const objectId = activity.objectId;
      if (objectId == null) return;
      const handle = ctx.getHandleFromActorUri(objectId);
      if (handle !== "i") return;
      const follower = await activity.getActor();
      if (!isActor(follower)) return;
      const accepts = await acceptsFollowFrom(follower);
      if (!accepts) {
        logger.debug(
          "Does not accept follow from {actor}.",
          { actor: follower.id?.href },
        );
        return;
      }
      logger.debug(
        "Accepting follow from {actor}.",
        { actor: follower.id?.href },
      );
      await ctx.sendActivity(
        { handle },
        follower,
        new Accept({
          actor: ctx.getActorUri(handle),
          object: activity.id,
        }),
      );
    }
  });

function printServerInfo(fedCtx: Context<number>): void {
  new Table(
    [
      new Cell("Actor handle:").align("right"),
      colors.green(`i@${fedCtx.getActorUri("i").hostname}`),
    ],
    [
      new Cell("Actor URI:").align("right"),
      colors.green(fedCtx.getActorUri("i").href),
    ],
    [
      new Cell("Actor inbox:").align("right"),
      colors.green(fedCtx.getInboxUri("i").href),
    ],
    [
      new Cell("Shared inbox:").align("right"),
      colors.green(fedCtx.getInboxUri().href),
    ],
  ).chars(tableStyle).border().render();
}

function printActivityEntry(
  idx: number,
  entry: ActivityEntry,
): void {
  const request = entry.request.clone();
  const response = entry.response?.clone();
  const url = new URL(request.url);
  const activity = entry.activity;
  new Table(
    [
      new Cell("Request #:").align("right"),
      colors.bold(idx.toString()),
    ],
    [
      new Cell("Activity type:").align("right"),
      activity == null
        ? colors.red("failed to parse")
        : colors.green(activity.constructor.name),
    ],
    [
      new Cell("HTTP request:").align("right"),
      `${
        request.method === "POST"
          ? colors.green("POST")
          : colors.red(request.method)
      } ${url.pathname + url.search}`,
    ],
    ...(response == null ? [] : [[
      new Cell("HTTP response:").align("right"),
      `${
        response.ok
          ? colors.green(response.status.toString())
          : colors.red(response.status.toString())
      } ${response.statusText}`,
    ]]),
    [
      new Cell("Details").align("right"),
      new URL(`/r/${idx}`, url).href,
    ],
  ).chars(tableStyle).border().render();
}

const app = new Hono();

app.get("/", (c) => c.redirect("/r"));

app.get("/r", (c) =>
  c.html(
    <ActivityListPage entries={activities} />,
  ));

app.get(
  "/r/:idx{[0-9]+}",
  (c) => {
    const idx = parseInt(c.req.param("idx"));
    const tab = c.req.query("tab") ?? "request";
    const activity = activities[idx];
    if (activity == null) return c.notFound();
    return c.html(
      <ActivityEntryPage idx={idx} entry={activity} tabPage={tab} />,
    );
  },
);

async function fetch(request: Request): Promise<Response> {
  const timestamp = Temporal.Now.instant();
  const idx = activities.length;
  const pathname = new URL(request.url).pathname;
  const inboxRequest = pathname === "/inbox" || pathname.startsWith("/i/inbox");
  if (inboxRequest) {
    recordingSink.startRecording();
    activities.push({ timestamp, request: request.clone(), logs: [] });
  }
  const response = await federation.fetch(request, {
    contextData: inboxRequest ? idx : -1,
    onNotAcceptable: app.fetch.bind(app),
    onNotFound: app.fetch.bind(app),
    onUnauthorized: app.fetch.bind(app),
  });
  if (inboxRequest) {
    recordingSink.stopRecording();
    activities[idx].response = response;
    activities[idx].logs = recordingSink.getRecords();
    printActivityEntry(idx, activities[idx]);
  }
  return response;
}
Loading