Unverified Commit 5c6e1394 authored by Hong Minhee's avatar Hong Minhee
Browse files

Forwarding activities

parent d53fa6d3
Loading
Loading
Loading
Loading
+13 −0
Original line number Diff line number Diff line
@@ -28,6 +28,18 @@ To be released.
     -  Added `attachSignature()` function.
     -  Added `detachSignature()` function.

 -  In inbox listeners, a received activity now can be forwarded to another
    server.  [[#137]]

     -  Added `InboxContext` interface.
     -  Added `ForwardActivityOptions` interface.
     -  The first parameter of the `InboxListener` callback type became
        `InboxContext` (was `Context`).

 -  `Context.sendActivity()` method now adds Object Integrity Proofs to
    the activity to be sent only once.  It had added Object Integrity Proofs
    to the activity for every recipient before.

 -  WebFinger responses now include <http://webfinger.net/rel/avatar> links
    if the `Actor` object returned by the actor dispatcher has `icon`/`icons`
    property.
@@ -43,6 +55,7 @@ To be released.

[Linked Data Signatures]: https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/
[#135]: https://github.com/dahlia/fedify/issues/135
[#137]: https://github.com/dahlia/fedify/issues/137


Version 0.15.1
+68 −0
Original line number Diff line number Diff line
@@ -277,6 +277,74 @@ federation
> to the error handler.


Forwarding activities to another server
---------------------------------------

*This API is available since Fedify 1.0.0.*

Sometimes, you may want to forward incoming activities to another server.
For example, you may want to forward `Flag` activities to a moderation server.
Or you may want to forward `Create` activities which reply to your server to
your followers so that they can see the replies.

The problem is that the recipients of the forwarded activities will not trust
the forwarded activities unless they are signed by the original sender, not by
you.  You might think that you can just `~Context.sendActivity()` the received
activity to the recipient in your inbox listener, but it doesn't work because
the signature made by the original sender is stripped when the received activity
is passed to the inbox listener, and `~Context.sendActivity()` will sign the
activity with your key.

To solve this problem, you can use the `~InboxContext.forwardActivity()` method
in your inbox listener.  It forwards the received activity without any
modification, so the signature made by the original sender is preserved
(if the activity is signed using by the original sender).

The following shows an example of forwarding `Create` activities to followers:

~~~~ typescript twoslash
import { Create, type Federation } from "@fedify/fedify";
const federation: Federation<void> = null as unknown as Federation<void>;
federation.setInboxListeners("/{handle}/inbox", "/inbox")
// ---cut-before---
.on(Create, async (ctx, create) => {
  if (create.toId == null) return;
  const to = ctx.parseUri(create.toId);
  if (to?.type !== "actor") return;
  const forwarder = to.handle;
  await ctx.forwardActivity({ handle: forwarder }, "followers");
})
~~~~

> [!NOTE]
> The `~InboxContext.forwardActivity()` method does not guarantee that the
> forwarded activity is successfully delivered to the recipient, since
> the original sender might  neither sign the activity using [Linked Data
> Signatures](./send.md#linked-data-signatures) nor [Object Integrity
> Proofs](./send.md#object-integrity-proofs).  In such cases, the recipient
> probably won't trust the forwarded activity.[^2]
>
> If you don't want to forward unsigned activities, you can turn on
> the `~ForwardActivityOptions.skipIfUnsigned` option in
> the `~InboxContext.forwardActivity()` method:
>
> ~~~~ typescript twoslash
> import { type InboxContext } from "@fedify/fedify";
> const ctx = null as unknown as InboxContext<void>;
> // ---cut-before---
> await ctx.forwardActivity(
>   { handle: "alice" },
>   "followers",
>   { skipIfUnsigned: true },
> );
> ~~~~

[^2]: Some implementations may try to verify the unsigned activity by fetching
      the original object from the original sender's server even if they
      don't trust the forwarded activity.  However, it is not guaranteed
      that all implementations do so.


Constructing inbox URIs
-----------------------

+5 −2
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@ import type { Actor } from "../vocab/actor.ts";
import type { Activity, CryptographicKey } from "../vocab/mod.ts";
import type { Object } from "../vocab/vocab.ts";
import type { PageItems } from "./collection.ts";
import type { Context, RequestContext } from "./context.ts";
import type { Context, InboxContext, RequestContext } from "./context.ts";
import type { SenderKeyPair } from "./send.ts";

/**
@@ -134,9 +134,11 @@ export type CollectionCursor<
 *
 * @typeParam TContextData The context data to pass to the {@link Context}.
 * @typeParam TActivity The type of activity to listen for.
 * @param context The inbox context.
 * @param activity The activity that was received.
 */
export type InboxListener<TContextData, TActivity extends Activity> = (
  context: Context<TContextData>,
  context: InboxContext<TContextData>,
  activity: TActivity,
) => void | Promise<void>;

@@ -144,6 +146,7 @@ export type InboxListener<TContextData, TActivity extends Activity> = (
 * A callback that handles errors in an inbox.
 *
 * @typeParam TContextData The context data to pass to the {@link Context}.
 * @param context The inbox context.
 */
export type InboxErrorHandler<TContextData> = (
  context: Context<TContextData>,
+59 −2
Original line number Diff line number Diff line
@@ -332,6 +332,48 @@ export interface RequestContext<TContextData> extends Context<TContextData> {
  getSignedKeyOwner(): Promise<Actor | null>;
}

/**
 * A context for inbox listeners.
 * @since 1.0.0
 */
export interface InboxContext<TContextData> extends Context<TContextData> {
  /**
   * Forwards a received activity to the recipients' inboxes.  The forwarded
   * activity will be signed in HTTP Signatures by the forwarder, but its
   * payload will not be modified, i.e., Linked Data Signatures and Object
   * Integrity Proofs will not be added.  Therefore, if the activity is not
   * signed (i.e., it has neither Linked Data Signatures nor Object Integrity
   * Proofs), the recipient probably will not trust the activity.
   * @param forwarder The forwarder's handle or the forwarder's key pair(s).
   * @param recipients The recipients of the activity.
   * @param options Options for forwarding the activity.
   * @since 1.0.0
   */
  forwardActivity(
    forwarder: SenderKeyPair | SenderKeyPair[] | { handle: string },
    recipients: Recipient | Recipient[],
    options?: ForwardActivityOptions,
  ): Promise<void>;

  /**
   * Forwards a received activity to the recipients' inboxes.  The forwarded
   * activity will be signed in HTTP Signatures by the forwarder, but its
   * payload will not be modified, i.e., Linked Data Signatures and Object
   * Integrity Proofs will not be added.  Therefore, if the activity is not
   * signed (i.e., it has neither Linked Data Signatures nor Object Integrity
   * Proofs), the recipient probably will not trust the activity.
   * @param forwarder The forwarder's handle.
   * @param recipients In this case, it must be `"followers"`.
   * @param options Options for forwarding the activity.
   * @since 1.0.0
   */
  forwardActivity(
    forwarder: { handle: string },
    recipients: "followers",
    options?: ForwardActivityOptions,
  ): Promise<void>;
}

/**
 * A result of parsing an URI.
 */
@@ -384,8 +426,7 @@ export type ParseUriResult =
  | { type: "featuredTags"; handle: string };

/**
 * Options for {@link Context.sendActivity} method and
 * {@link Federation.sendActivity} method.
 * Options for {@link Context.sendActivity} method.
 */
export interface SendActivityOptions {
  /**
@@ -413,6 +454,22 @@ export interface SendActivityOptions {
  excludeBaseUris?: URL[];
}

/**
 * Options for {@link InboxContext.forwardActivity} method.
 * @since 1.0.0
 */
export interface ForwardActivityOptions extends SendActivityOptions {
  /**
   * Whether to skip forwarding the activity if it is not signed, i.e., it has
   * neither Linked Data Signatures nor Object Integrity Proofs.
   *
   * If the activity is not signed, the recipient probably will not trust the
   * activity.  Therefore, it is recommended to skip forwarding the activity
   * if it is not signed.
   */
  skipIfUnsigned: boolean;
}

/**
 * A pair of a public key and a private key in various formats.
 * @since 0.10.0
+28 −1
Original line number Diff line number Diff line
import { assert, assertEquals, assertFalse } from "@std/assert";
import { signRequest } from "../sig/http.ts";
import { createRequestContext } from "../testing/context.ts";
import {
  createInboxContext,
  createRequestContext,
} from "../testing/context.ts";
import { mockDocumentLoader } from "../testing/docloader.ts";
import {
  rsaPrivateKey3,
@@ -1076,6 +1079,9 @@ test("handleInbox()", async () => {
  let response = await handleInbox(unsignedRequest, {
    handle: null,
    context: unsignedContext,
    inboxContextFactory(_activity) {
      return createInboxContext(unsignedContext);
    },
    ...inboxOptions,
    actorDispatcher: undefined,
  });
@@ -1086,6 +1092,9 @@ test("handleInbox()", async () => {
  response = await handleInbox(unsignedRequest, {
    handle: "nobody",
    context: unsignedContext,
    inboxContextFactory(_activity) {
      return createInboxContext(unsignedContext);
    },
    ...inboxOptions,
  });
  assertEquals(onNotFoundCalled, unsignedRequest);
@@ -1095,6 +1104,9 @@ test("handleInbox()", async () => {
  response = await handleInbox(unsignedRequest, {
    handle: null,
    context: unsignedContext,
    inboxContextFactory(_activity) {
      return createInboxContext(unsignedContext);
    },
    ...inboxOptions,
  });
  assertEquals(onNotFoundCalled, null);
@@ -1103,6 +1115,9 @@ test("handleInbox()", async () => {
  response = await handleInbox(unsignedRequest, {
    handle: "someone",
    context: unsignedContext,
    inboxContextFactory(_activity) {
      return createInboxContext(unsignedContext);
    },
    ...inboxOptions,
  });
  assertEquals(onNotFoundCalled, null);
@@ -1123,6 +1138,9 @@ test("handleInbox()", async () => {
  response = await handleInbox(signedRequest, {
    handle: null,
    context: signedContext,
    inboxContextFactory(_activity) {
      return createInboxContext(unsignedContext);
    },
    ...inboxOptions,
  });
  assertEquals(onNotFoundCalled, null);
@@ -1131,6 +1149,9 @@ test("handleInbox()", async () => {
  response = await handleInbox(signedRequest, {
    handle: "someone",
    context: signedContext,
    inboxContextFactory(_activity) {
      return createInboxContext(unsignedContext);
    },
    ...inboxOptions,
  });
  assertEquals(onNotFoundCalled, null);
@@ -1139,6 +1160,9 @@ test("handleInbox()", async () => {
  response = await handleInbox(unsignedRequest, {
    handle: null,
    context: unsignedContext,
    inboxContextFactory(_activity) {
      return createInboxContext(unsignedContext);
    },
    ...inboxOptions,
    skipSignatureVerification: true,
  });
@@ -1148,6 +1172,9 @@ test("handleInbox()", async () => {
  response = await handleInbox(unsignedRequest, {
    handle: "someone",
    context: unsignedContext,
    inboxContextFactory(_activity) {
      return createInboxContext(unsignedContext);
    },
    ...inboxOptions,
    skipSignatureVerification: true,
  });
Loading