Unverified Commit d0cd81c1 authored by Hong Minhee's avatar Hong Minhee
Browse files

Generalize collections & followers collection

parent 811b623e
Loading
Loading
Loading
Loading
+40 −1
Original line number Diff line number Diff line
@@ -10,7 +10,12 @@ import {
  Undo,
} from "fedify/vocab/mod.ts";
import { getBlog } from "../models/blog.ts";
import { addFollower, removeFollower } from "../models/follower.ts";
import {
  addFollower,
  countFollowers,
  getFollowers,
  removeFollower,
} from "../models/follower.ts";
import { openKv } from "../models/kv.ts";
import { countPosts, getPosts, toNote } from "../models/post.ts";

@@ -37,6 +42,7 @@ federation.setActorDispatcher("/users/{handle}", async (ctx, handle, key) => {
    url: new URL("/", ctx.request.url),
    outbox: ctx.getOutboxUri(handle),
    inbox: ctx.getInboxUri(handle),
    followers: ctx.getFollowersUri(handle),
    publicKey: key,
  });
})
@@ -148,3 +154,36 @@ federation.setInboxListeners("/users/{handle}/inbox")
    }
  })
  .onError((e) => console.error(e));

federation
  .setFollowersDispatcher(
    "/users/{handle}/followers",
    async (_ctx, handle, cursor) => {
      const blog = await getBlog();
      if (blog == null) return null;
      else if (blog.handle !== handle) return null;
      if (cursor == null) return null;
      const { followers, nextCursor } = await getFollowers(
        undefined,
        // Treat the empty string as the first cursor:
        cursor === "" ? undefined : cursor,
      );
      return {
        items: followers.map((f) => new URL(f.id)),
        nextCursor,
      };
    },
  )
  .setCounter(async (_ctx, handle) => {
    const blog = await getBlog();
    if (blog == null) return null;
    else if (blog.handle !== handle) return null;
    return await countFollowers();
  })
  .setFirstCursor(async (_ctx, handle) => {
    const blog = await getBlog();
    if (blog == null) return null;
    else if (blog.handle !== handle) return null;
    // Treat the empty string as the first cursor:
    return "";
  });
+7 −7
Original line number Diff line number Diff line
@@ -22,26 +22,26 @@ export type ActorKeyPairDispatcher<TContextData> = (
) => CryptoKeyPair | null | Promise<CryptoKeyPair | null>;

/**
 * A callback that dispatches an outbox.
 * A callback that dispatches a collection.
 */
export type OutboxDispatcher<TContextData> = (
export type CollectionDispatcher<TItem, TContextData> = (
  context: RequestContext<TContextData>,
  handle: string,
  cursor: string | null,
) => Page<Activity> | null | Promise<Page<Activity> | null>;
) => Page<TItem> | null | Promise<Page<TItem> | null>;

/**
 * A callback that counts the number of activities in an outbox.
 * A callback that counts the number of items in a collection.
 */
export type OutboxCounter<TContextData> = (
export type CollectionCounter<TContextData> = (
  context: RequestContext<TContextData>,
  handle: string,
) => number | bigint | null | Promise<number | bigint | null>;

/**
 * A callback that returns a cursor for an outbox.
 * A callback that returns a cursor for a collection.
 */
export type OutboxCursor<TContextData> = (
export type CollectionCursor<TContextData> = (
  context: RequestContext<TContextData>,
  handle: string,
) => string | null | Promise<string | null>;
+7 −0
Original line number Diff line number Diff line
@@ -37,6 +37,13 @@ export interface Context<TContextData> {
   */
  getInboxUri(handle: string): URL;

  /**
   * Builds the URI of an actor's followers collection with the given handle.
   * @param handle The actor's handle.
   * @returns The actor's followers collection URI.
   */
  getFollowersUri(handle: string): URL;

  /**
   * Gets a public {@link CryptographicKey} for an actor, if any exists.
   * @param handle The actor's handle.
+46 −41
Original line number Diff line number Diff line
import { accepts } from "https://deno.land/std@0.217.0/http/mod.ts";
import {
  ActorDispatcher,
  CollectionCounter,
  CollectionCursor,
  CollectionDispatcher,
  InboxListener,
  OutboxCounter,
  OutboxCursor,
@@ -12,6 +15,8 @@ import { DocumentLoader } from "../runtime/docloader.ts";
import { isActor } from "../vocab/actor.ts";
import {
  Activity,
  Link,
  Object,
  OrderedCollection,
  OrderedCollectionPage,
} from "../vocab/mod.ts";
@@ -70,33 +75,36 @@ export async function handleActor<TContextData>(
  });
}

export interface OutboxHandlerParameters<TContextData> {
export interface CollectionHandlerParameters<TItem, TContextData> {
  handle: string;
  context: RequestContext<TContextData>;
  documentLoader: DocumentLoader;
  outboxDispatcher?: OutboxDispatcher<TContextData>;
  outboxCounter?: OutboxCounter<TContextData>;
  outboxFirstCursor?: OutboxCursor<TContextData>;
  outboxLastCursor?: OutboxCursor<TContextData>;
  collectionDispatcher?: CollectionDispatcher<TItem, TContextData>;
  collectionCounter?: CollectionCounter<TContextData>;
  collectionFirstCursor?: CollectionCursor<TContextData>;
  collectionLastCursor?: CollectionCursor<TContextData>;
  onNotFound(request: Request): Response | Promise<Response>;
  onNotAcceptable(request: Request): Response | Promise<Response>;
}

export async function handleOutbox<TContextData>(
export async function handleCollection<
  TItem extends URL | Object | Link,
  TContextData,
>(
  request: Request,
  {
    handle,
    context,
    documentLoader,
    outboxCounter,
    outboxFirstCursor,
    outboxLastCursor,
    outboxDispatcher,
    collectionDispatcher,
    collectionCounter,
    collectionFirstCursor,
    collectionLastCursor,
    onNotFound,
    onNotAcceptable,
  }: OutboxHandlerParameters<TContextData>,
  }: CollectionHandlerParameters<TItem, TContextData>,
): Promise<Response> {
  if (outboxDispatcher == null) {
  if (collectionDispatcher == null) {
    const response = onNotFound(request);
    return response instanceof Promise ? await response : response;
  }
@@ -108,16 +116,16 @@ export async function handleOutbox<TContextData>(
  const cursor = url.searchParams.get("cursor");
  let collection: OrderedCollection | OrderedCollectionPage;
  if (cursor == null) {
    const firstCursorPromise = outboxFirstCursor?.(context, handle);
    const firstCursorPromise = collectionFirstCursor?.(context, handle);
    const firstCursor = firstCursorPromise instanceof Promise
      ? await firstCursorPromise
      : firstCursorPromise;
    const totalItemsPromise = outboxCounter?.(context, handle);
    const totalItemsPromise = collectionCounter?.(context, handle);
    const totalItems = totalItemsPromise instanceof Promise
      ? await totalItemsPromise
      : totalItemsPromise;
    if (firstCursor == null) {
      const pagePromise = outboxDispatcher(context, handle, null);
      const pagePromise = collectionDispatcher(context, handle, null);
      const page = pagePromise instanceof Promise
        ? await pagePromise
        : pagePromise;
@@ -131,26 +139,25 @@ export async function handleOutbox<TContextData>(
        items,
      });
    } else {
      const lastCursorPromise = outboxLastCursor?.(context, handle);
      const lastCursorPromise = collectionLastCursor?.(context, handle);
      const lastCursor = lastCursorPromise instanceof Promise
        ? await lastCursorPromise
        : lastCursorPromise;
      const first = new URL(context.url);
      first.searchParams.set("cursor", firstCursor);
      let last = null;
      if (lastCursor != null) {
        last = new URL(context.url);
        last.searchParams.set("cursor", lastCursor);
      }
      collection = new OrderedCollection({
        totalItems: Number(totalItems),
        first: new URL(
          `${context.getOutboxUri(handle).href}?cursor=${
            encodeURIComponent(firstCursor)
          }`,
        ),
        last: lastCursor == null ? null : new URL(
          `${context.getOutboxUri(handle).href}?cursor=${
            encodeURIComponent(lastCursor)
          }`,
        ),
        first,
        last,
      });
    }
  } else {
    const pagePromise = outboxDispatcher(context, handle, cursor);
    const pagePromise = collectionDispatcher(context, handle, cursor);
    const page = pagePromise instanceof Promise
      ? await pagePromise
      : pagePromise;
@@ -159,19 +166,17 @@ export async function handleOutbox<TContextData>(
      return response instanceof Promise ? await response : response;
    }
    const { items, prevCursor, nextCursor } = page;
    collection = new OrderedCollectionPage({
      prev: prevCursor == null ? null : new URL(
        `${context.getOutboxUri(handle).href}?cursor=${
          encodeURIComponent(prevCursor)
        }`,
      ),
      next: nextCursor == null ? null : new URL(
        `${context.getOutboxUri(handle).href}?cursor=${
          encodeURIComponent(nextCursor)
        }`,
      ),
      items,
    });
    let prev = null;
    if (prevCursor != null) {
      prev = new URL(context.url);
      prev.searchParams.set("cursor", prevCursor);
    }
    let next = null;
    if (nextCursor != null) {
      next = new URL(context.url);
      next.searchParams.set("cursor", nextCursor);
    }
    collection = new OrderedCollectionPage({ prev, next, items });
  }
  const jsonLd = await collection.toJsonLd({ documentLoader });
  return new Response(JSON.stringify(jsonLd), {
@@ -293,7 +298,7 @@ export async function handleInbox<TContextData>(
        headers: { "Content-Type": "text/plain; charset=utf-8" },
      });
    }
    cls = Object.getPrototypeOf(cls);
    cls = globalThis.Object.getPrototypeOf(cls);
  }
  const listener = inboxListeners.get(cls)!;
  const promise = listener(context, activity);
+95 −30
Original line number Diff line number Diff line
@@ -10,13 +10,13 @@ import { handleWebFinger } from "../webfinger/handler.ts";
import {
  ActorDispatcher,
  ActorKeyPairDispatcher,
  CollectionCounter,
  CollectionCursor,
  CollectionDispatcher,
  InboxListener,
  OutboxCounter,
  OutboxCursor,
  OutboxDispatcher,
} from "./callback.ts";
import { Context, RequestContext } from "./context.ts";
import { handleActor, handleInbox, handleOutbox } from "./handler.ts";
import { handleActor, handleCollection, handleInbox } from "./handler.ts";
import { OutboxMessage } from "./queue.ts";
import { Router, RouterError } from "./router.ts";
import { extractInboxes, sendActivity } from "./send.ts";
@@ -60,7 +60,8 @@ export class Federation<TContextData> {
  #kvPrefixes: FederationKvPrefixes;
  #router: Router;
  #actorCallbacks?: ActorCallbacks<TContextData>;
  #outboxCallbacks?: OutboxCallbacks<TContextData>;
  #outboxCallbacks?: CollectionCallbacks<Activity, TContextData>;
  #followersCallbacks?: CollectionCallbacks<Actor | URL, TContextData>;
  #inboxListeners: Map<
    new (...args: unknown[]) => Activity,
    InboxListener<TContextData, Activity>
@@ -201,6 +202,13 @@ export class Federation<TContextData> {
        }
        return new URL(path, url);
      },
      getFollowersUri: (handle: string): URL => {
        const path = this.#router.build("followers", { handle });
        if (path == null) {
          throw new RouterError("No followers collection path registered.");
        }
        return new URL(path, url);
      },
      getActorKey: async (handle: string): Promise<CryptographicKey | null> => {
        let keyPair = this.#actorCallbacks?.keyPairDispatcher?.(
          contextData,
@@ -303,8 +311,8 @@ export class Federation<TContextData> {
   */
  setOutboxDispatcher(
    path: string,
    dispatcher: OutboxDispatcher<TContextData>,
  ): OutboxCallbackSetters<TContextData> {
    dispatcher: CollectionDispatcher<Activity, TContextData>,
  ): CollectionCallbackSetters<TContextData> {
    if (this.#router.has("outbox")) {
      throw new RouterError("Outbox dispatcher already set.");
    }
@@ -314,18 +322,63 @@ export class Federation<TContextData> {
        "Path for outbox dispatcher must have one variable: {handle}",
      );
    }
    const callbacks: OutboxCallbacks<TContextData> = { dispatcher };
    const callbacks: CollectionCallbacks<Activity, TContextData> = {
      dispatcher,
    };
    this.#outboxCallbacks = callbacks;
    const setters: OutboxCallbackSetters<TContextData> = {
      setCounter(counter: OutboxCounter<TContextData>) {
    const setters: CollectionCallbackSetters<TContextData> = {
      setCounter(counter: CollectionCounter<TContextData>) {
        callbacks.counter = counter;
        return setters;
      },
      setFirstCursor(cursor: CollectionCursor<TContextData>) {
        callbacks.firstCursor = cursor;
        return setters;
      },
      setLastCursor(cursor: CollectionCursor<TContextData>) {
        callbacks.lastCursor = cursor;
        return setters;
      },
    };
    return setters;
  }

  /**
   * Registers an followers collection dispatcher.
   * @param path The URI path pattern for the followers collection.  The syntax
   *             is based on URI Template
   *             ([RFC 6570](https://tools.ietf.org/html/rfc6570)).  The path
   *             must have one variable: `{handle}`.
   * @param dispatcher An outbox collection callback to register.
   * @throws {@link RouterError} Thrown if the path pattern is invalid.
   */
  setFollowersDispatcher(
    path: string,
    dispatcher: CollectionDispatcher<Actor | URL, TContextData>,
  ): CollectionCallbackSetters<TContextData> {
    if (this.#router.has("followers")) {
      throw new RouterError("Followers collection dispatcher already set.");
    }
    const variables = this.#router.add(path, "followers");
    if (variables.size !== 1 || !variables.has("handle")) {
      throw new RouterError(
        "Path for followers collection dispatcher must have one variable: {handle}",
      );
    }
    const callbacks: CollectionCallbacks<Actor | URL, TContextData> = {
      dispatcher,
    };
    this.#followersCallbacks = callbacks;
    const setters: CollectionCallbackSetters<TContextData> = {
      setCounter(counter: CollectionCounter<TContextData>) {
        callbacks.counter = counter;
        return setters;
      },
      setFirstCursor(cursor: OutboxCursor<TContextData>) {
      setFirstCursor(cursor: CollectionCursor<TContextData>) {
        callbacks.firstCursor = cursor;
        return setters;
      },
      setLastCursor(cursor: OutboxCursor<TContextData>) {
      setLastCursor(cursor: CollectionCursor<TContextData>) {
        callbacks.lastCursor = cursor;
        return setters;
      },
@@ -439,14 +492,14 @@ export class Federation<TContextData> {
          onNotAcceptable,
        });
      case "outbox":
        return await handleOutbox(request, {
        return await handleCollection(request, {
          handle: route.values.handle,
          context,
          documentLoader: this.#documentLoader,
          outboxDispatcher: this.#outboxCallbacks?.dispatcher,
          outboxCounter: this.#outboxCallbacks?.counter,
          outboxFirstCursor: this.#outboxCallbacks?.firstCursor,
          outboxLastCursor: this.#outboxCallbacks?.lastCursor,
          collectionDispatcher: this.#outboxCallbacks?.dispatcher,
          collectionCounter: this.#outboxCallbacks?.counter,
          collectionFirstCursor: this.#outboxCallbacks?.firstCursor,
          collectionLastCursor: this.#outboxCallbacks?.lastCursor,
          onNotFound,
          onNotAcceptable,
        });
@@ -462,6 +515,18 @@ export class Federation<TContextData> {
          inboxErrorHandler: this.#inboxErrorHandler,
          onNotFound,
        });
      case "followers":
        return await handleCollection(request, {
          handle: route.values.handle,
          context,
          documentLoader: this.#documentLoader,
          collectionDispatcher: this.#followersCallbacks?.dispatcher,
          collectionCounter: this.#followersCallbacks?.counter,
          collectionFirstCursor: this.#followersCallbacks?.firstCursor,
          collectionLastCursor: this.#followersCallbacks?.lastCursor,
          onNotFound,
          onNotAcceptable,
        });
      default: {
        const response = onNotFound(request);
        return response instanceof Promise ? await response : response;
@@ -505,28 +570,28 @@ export interface ActorCallbackSetters<TContextData> {
  ): ActorCallbackSetters<TContextData>;
}

interface OutboxCallbacks<TContextData> {
  dispatcher: OutboxDispatcher<TContextData>;
  counter?: OutboxCounter<TContextData>;
  firstCursor?: OutboxCursor<TContextData>;
  lastCursor?: OutboxCursor<TContextData>;
interface CollectionCallbacks<TItem, TContextData> {
  dispatcher: CollectionDispatcher<TItem, TContextData>;
  counter?: CollectionCounter<TContextData>;
  firstCursor?: CollectionCursor<TContextData>;
  lastCursor?: CollectionCursor<TContextData>;
}

/**
 * Additional settings for the outbox dispatcher.
 * Additional settings for a collection dispatcher.
 */
export interface OutboxCallbackSetters<TContextData> {
export interface CollectionCallbackSetters<TContextData> {
  setCounter(
    counter: OutboxCounter<TContextData>,
  ): OutboxCallbackSetters<TContextData>;
    counter: CollectionCounter<TContextData>,
  ): CollectionCallbackSetters<TContextData>;

  setFirstCursor(
    cursor: OutboxCursor<TContextData>,
  ): OutboxCallbackSetters<TContextData>;
    cursor: CollectionCursor<TContextData>,
  ): CollectionCallbackSetters<TContextData>;

  setLastCursor(
    cursor: OutboxCursor<TContextData>,
  ): OutboxCallbackSetters<TContextData>;
    cursor: CollectionCursor<TContextData>,
  ): CollectionCallbackSetters<TContextData>;
}

/**