Commit b82c680b authored by Grant's avatar Grant
Browse files

utils stub tradeout

parent 3587424a
Loading
Loading
Loading
Loading
+316 −0
Original line number Diff line number Diff line
import {
  Actor,
  ChatMessage,
  Context,
  Create,
  Delete,
  isActor,
  lookupObject,
  LookupObjectOptions,
  lookupWebFinger,
  Mention,
  Note,
  Tombstone,
} from "@fedify/fedify";
import { federation, USER_IDENTIFIER } from "./federation.js";
import { Temporal } from "@js-temporal/polyfill";
import { AuthSession } from "../../controllers/AuthSession.js";
import { IProfile } from "../instance/userMeta.js";
import { getSafeURL } from "../utils.js";

type BuildObjectOpts = {
  id: string;
  one_time_code: string;
  createdAt: Date;
  target: Actor;
};

export class APubLive {
  ctx: Context<void>;

  constructor(ctx: Context<void>) {
    this.ctx = ctx;
  }

  static options: (
    ctx: Context<void>,
    opts?: Partial<LookupObjectOptions>,
  ) => LookupObjectOptions = (ctx, opts = {}) => ({
    ...ctx,
    allowPrivateAddress: process.env.NODE_ENV === "development",
    ...opts,
  });

  static get accountHandle() {
    return USER_IDENTIFIER + "@" + new URL(process.env.OIDC_ISSUER).host;
  }

  static get() {
    return new APubLive(
      federation.createContext(new URL("/", process.env.OIDC_ISSUER)),
    );
  }

  /**
   * Build IProfile from just an ActivityPub identifier
   * @param identifier
   */
  static async buildProfile(
    identifier: string | URL,
  ): Promise<IProfile | null> {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });

    let profile: IProfile = {
      sub: identifier.toString(),
    };

    // webfinger should support ?resource being a fully qualified profile page
    // but... lemmy does not support it and returns a 400 bad request

    const webfinger = await this.lookupWebfinger(identifier).catch(
      (_e) => null,
    );
    const actor = await this.lookupActor(identifier).catch((_e) => undefined);

    const perferred_username =
      webfinger?.subject?.replace("acct:", "") ||
      actor!.preferredUsername!.toString() + "@" + new URL(identifier).hostname;
    const profile_url =
      webfinger?.links?.find(
        (l) => l.rel === "http://webfinger.net/rel/profile-page",
      )?.href || identifier.toString();

    profile.name = actor?.name?.toString();
    profile.preferred_username = perferred_username;
    profile.profile = profile_url;
    profile.raw_picture = (
      await actor?.getIcon(this.options(ctx, { documentLoader }))
    )?.url?.href?.toString();
    if (profile.raw_picture) profile.picture = getSafeURL(profile.raw_picture);

    return profile;
  }

  static async lookupWebfinger(identifier: string | URL) {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });
    return lookupWebFinger(identifier, this.options(ctx, { documentLoader }));
  }

  static async lookupActor(identifier: string | URL): Promise<Actor | null> {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });
    const object = await lookupObject(
      identifier,
      this.options(ctx, { documentLoader }),
    );

    if (isActor(object)) return object;
    return null;
  }

  static async sendDM(session: AuthSession) {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });
    const recipient = await lookupObject(
      session.user_sub,
      this.options(ctx, { documentLoader }),
    );

    if (!isActor(recipient)) throw new Error("Not an actor");

    const apub = new APubLive(ctx);
    const opts: BuildObjectOpts = {
      id: session.id,
      one_time_code: session.code,
      createdAt: session.createdAt,
      target: recipient,
    };

    try {
      await apub.sendChatMessage(
        session.id,
        recipient,
        apub.build("ChatMessage", opts),
      );
    } catch (e) {
      if (process.env.NODE_ENV === "development") {
        // fediverse services may return non 2xx status codes, causing an error
        // we can silently try another object if thats the case
        // we log it here for development usage, but ignore otherwise
        console.error(`Failed to send ChatMessage to ${session.user_sub}`, e);
      }
    }

    try {
      await apub.sendNote(session.id, recipient, apub.build("Note", opts));
    } catch (e) {
      if (process.env.NODE_ENV === "development") {
        // fediverse services may return non 2xx status codes, causing an error
        // we can silently try another object if thats the case
        // we log it here for development usage, but ignore otherwise
        console.error(`Failed to send Note to ${session.user_sub}`, e);
      }
    }
  }

  static async deleteDM(session: AuthSession) {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });
    const recipient = await lookupObject(
      session.user_sub,
      this.options(ctx, { documentLoader }),
    );

    if (!isActor(recipient)) throw new Error("Not an actor");

    const apub = new APubLive(ctx);

    try {
      await apub.deleteChatMessage(session.id, recipient);
    } catch (e) {
      if (process.env.NODE_ENV === "development") {
        // fediverse services may return non 2xx status codes, causing an error
        // we can silently try another object if thats the case
        // we log it here for development usage, but ignore otherwise
        console.error(`Failed to delete ChatMessage to ${session.user_sub}`, e);
      }
    }

    try {
      await apub.deleteNote(session.id, recipient);
    } catch (e) {
      if (process.env.NODE_ENV === "development") {
        // fediverse services may return non 2xx status codes, causing an error
        // we can silently try another object if thats the case
        // we log it here for development usage, but ignore otherwise
        console.error(`Failed to delete Note to ${session.user_sub}`, e);
      }
    }
  }

  /**
   * Send a ChatMessage message targeted at Actor
   *
   * Not many fediverse software supports this, but Lemmy <0.19 uses this exclusively for DMs
   */
  async sendChatMessage(id: string, target: Actor, content: ChatMessage) {
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    await this.ctx.sendActivity(
      { identifier: USER_IDENTIFIER },
      [target],
      new Create({
        id: new URL("#create", this.ctx.getObjectUri(ChatMessage, { id })),
        actor: sender,
        to: target,
        object: content,
      }),
      { fanout: "skip" },
    );
  }

  private async deleteChatMessage(id: string, target: Actor) {
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    await this.ctx.sendActivity(
      { identifier: USER_IDENTIFIER },
      [target],
      new Delete({
        id: new URL("#delete", this.ctx.getObjectUri(ChatMessage, { id })),
        object: this.ctx.getObjectUri(ChatMessage, { id }),
        actor: sender,
        to: target,
      }),
    );
  }

  /**
   * Send a private Note message targeted at Actor
   *
   * Most fediverse software supports this
   */
  async sendNote(id: string, target: Actor, content: Note) {
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    await this.ctx.sendActivity(
      { identifier: USER_IDENTIFIER },
      [target],
      new Create({
        id: new URL("#create", this.ctx.getObjectUri(Note, { id })),
        actor: sender,
        to: target,
        object: content,
      }),
      { fanout: "skip" },
    );
  }

  private async deleteNote(id: string, target: Actor) {
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    await this.ctx.sendActivity(
      { identifier: USER_IDENTIFIER },
      [target],
      new Delete({
        id: new URL("#delete", this.ctx.getObjectUri(Note, { id })),
        object: new Tombstone({
          id: this.ctx.getObjectUri(Note, { id }),
        }),
        actor: sender,
        to: target,
      }),
    );
  }

  build(type: "ChatMessage", opts: BuildObjectOpts): ChatMessage;
  build(type: "Note", opts: BuildObjectOpts): Note;
  build(type: "ChatMessage" | "Note", opts: BuildObjectOpts): unknown {
    if (!type) throw new Error();

    const { id, one_time_code, target, createdAt } = opts;
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    const content = {
      content: `Code: ${one_time_code}

Do not share this code. This code is used to identify you.`,
    };

    switch (type) {
      case "ChatMessage":
        return new ChatMessage({
          id: this.ctx.getObjectUri(ChatMessage, { id }),
          attribution: sender,
          to: target.id!,
          published: Temporal.Instant.from(createdAt.toISOString()),
          ...content,
        });
      case "Note":
        return new Note({
          id: this.ctx.getObjectUri(Note, { id }),
          attribution: sender,
          to: target.id!,
          published: Temporal.Instant.from(createdAt.toISOString()),
          tags: [
            new Mention({
              href: target.id,
              name: target.id!.toString(),
            }),
          ],
          ...content,
        });
    }
  }
}
+20 −8
Original line number Diff line number Diff line
@@ -13,10 +13,10 @@ import {
} from "@fedify/fedify";
import { IProfile } from "../instance/userMeta.js";
import { USER_IDENTIFIER } from "./federation.js";
import { APub } from "./utils.js";
import { APubLive } from "./utils.live.js";
import { AuthSession } from "../../controllers/AuthSession.js";

export class APubStub extends APub {
export class APubStub extends APubLive {
  constructor() {
    super(null as any);
  }
@@ -24,7 +24,7 @@ export class APubStub extends APub {
  static options = () => ({});

  static get accountHandle() {
    return USER_IDENTIFIER + "@localhost";
    return process.env.DEV_APUB_STUB_HANDLE ?? USER_IDENTIFIER + "@localhost";
  }

  static get() {
@@ -50,14 +50,26 @@ export class APubStub extends APub {
  }

  static async lookupActor(identifier: string | URL): Promise<Actor | null> {
    // identifier can be a handle or URL
    const id = String(identifier).startsWith("http")
      ? new URL(identifier)
      : new URL(
          `/users/${String(identifier).split("@")[0]}`,
          `https://${String(identifier).split("@").at(-1)}`,
        );

    return new Person({
      id: new URL(identifier),
      id,
    });
  }

  static async sendDM(session: AuthSession) {}
  static async sendDM(session: AuthSession) {
    console.debug("APubStub: sendDM", session);
  }

  static async deleteDM(session: AuthSession) {}
  static async deleteDM(session: AuthSession) {
    console.debug("APubStub: deleteDM", session);
  }

  async sendChatMessage(id: string, target: Actor, content: ChatMessage) {}

@@ -69,5 +81,5 @@ export class APubStub extends APub {
  ): any {}
}

const _staticTypeCheck: typeof APub = APubStub;
const _staticTypeCheck: typeof APubLive = APubStub;
_staticTypeCheck.get(); // to ignore "unused variable" but still trigger type check
+4 −315
Original line number Diff line number Diff line
import {
  Actor,
  ChatMessage,
  Context,
  Create,
  Delete,
  isActor,
  lookupObject,
  LookupObjectOptions,
  lookupWebFinger,
  Mention,
  Note,
  Tombstone,
} from "@fedify/fedify";
import { federation, USER_IDENTIFIER } from "./federation.js";
import { Temporal } from "@js-temporal/polyfill";
import { AuthSession } from "../../controllers/AuthSession.js";
import { IProfile } from "../instance/userMeta.js";
import { getSafeURL } from "../utils.js";
import { APubLive } from "./utils.live.js";
import { APubStub } from "./utils.stub.js";

type BuildObjectOpts = {
  id: string;
  one_time_code: string;
  createdAt: Date;
  target: Actor;
};

export class APub {
  ctx: Context<void>;

  constructor(ctx: Context<void>) {
    this.ctx = ctx;
  }

  static options: (
    ctx: Context<void>,
    opts?: Partial<LookupObjectOptions>
  ) => LookupObjectOptions = (ctx, opts = {}) => ({
    ...ctx,
    allowPrivateAddress: process.env.NODE_ENV === "development",
    ...opts,
  });

  static get accountHandle() {
    return USER_IDENTIFIER + "@" + new URL(process.env.OIDC_ISSUER).host;
  }

  static get() {
    return new APub(
      federation.createContext(new URL("/", process.env.OIDC_ISSUER))
    );
  }

  /**
   * Build IProfile from just an ActivityPub identifier
   * @param identifier
   */
  static async buildProfile(
    identifier: string | URL
  ): Promise<IProfile | null> {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });

    let profile: IProfile = {
      sub: identifier.toString(),
    };

    // webfinger should support ?resource being a fully qualified profile page
    // but... lemmy does not support it and returns a 400 bad request

    const webfinger = await this.lookupWebfinger(identifier).catch(
      (_e) => null
    );
    const actor = await this.lookupActor(identifier).catch((_e) => undefined);

    const perferred_username =
      webfinger?.subject?.replace("acct:", "") ||
      actor!.preferredUsername!.toString() + "@" + new URL(identifier).hostname;
    const profile_url =
      webfinger?.links?.find(
        (l) => l.rel === "http://webfinger.net/rel/profile-page"
      )?.href || identifier.toString();

    profile.name = actor?.name?.toString();
    profile.preferred_username = perferred_username;
    profile.profile = profile_url;
    profile.raw_picture = (
      await actor?.getIcon(this.options(ctx, { documentLoader }))
    )?.url?.href?.toString();
    if (profile.raw_picture) profile.picture = getSafeURL(profile.raw_picture);

    return profile;
  }

  static async lookupWebfinger(identifier: string | URL) {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });
    return lookupWebFinger(identifier, this.options(ctx, { documentLoader }));
  }

  static async lookupActor(identifier: string | URL): Promise<Actor | null> {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });
    const object = await lookupObject(
      identifier,
      this.options(ctx, { documentLoader })
    );

    if (isActor(object)) return object;
    return null;
  }

  static async sendDM(session: AuthSession) {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });
    const recipient = await lookupObject(
      session.user_sub,
      this.options(ctx, { documentLoader })
    );

    if (!isActor(recipient)) throw new Error("Not an actor");

    const apub = new APub(ctx);
    const opts: BuildObjectOpts = {
      id: session.id,
      one_time_code: session.code,
      createdAt: session.createdAt,
      target: recipient,
    };

    try {
      await apub.sendChatMessage(
        session.id,
        recipient,
        apub.build("ChatMessage", opts)
      );
    } catch (e) {
      if (process.env.NODE_ENV === "development") {
        // fediverse services may return non 2xx status codes, causing an error
        // we can silently try another object if thats the case
        // we log it here for development usage, but ignore otherwise
        console.error(`Failed to send ChatMessage to ${session.user_sub}`, e);
      }
    }

    try {
      await apub.sendNote(session.id, recipient, apub.build("Note", opts));
    } catch (e) {
      if (process.env.NODE_ENV === "development") {
        // fediverse services may return non 2xx status codes, causing an error
        // we can silently try another object if thats the case
        // we log it here for development usage, but ignore otherwise
        console.error(`Failed to send Note to ${session.user_sub}`, e);
      }
    }
  }

  static async deleteDM(session: AuthSession) {
    const ctx = federation.createContext(new URL("/", process.env.OIDC_ISSUER));
    const documentLoader = await ctx.getDocumentLoader({
      identifier: USER_IDENTIFIER,
    });
    const recipient = await lookupObject(
      session.user_sub,
      this.options(ctx, { documentLoader })
    );

    if (!isActor(recipient)) throw new Error("Not an actor");

    const apub = new APub(ctx);

    try {
      await apub.deleteChatMessage(session.id, recipient);
    } catch (e) {
      if (process.env.NODE_ENV === "development") {
        // fediverse services may return non 2xx status codes, causing an error
        // we can silently try another object if thats the case
        // we log it here for development usage, but ignore otherwise
        console.error(`Failed to delete ChatMessage to ${session.user_sub}`, e);
      }
    }

    try {
      await apub.deleteNote(session.id, recipient);
    } catch (e) {
      if (process.env.NODE_ENV === "development") {
        // fediverse services may return non 2xx status codes, causing an error
        // we can silently try another object if thats the case
        // we log it here for development usage, but ignore otherwise
        console.error(`Failed to delete Note to ${session.user_sub}`, e);
      }
    }
  }

  /**
   * Send a ChatMessage message targeted at Actor
   *
   * Not many fediverse software supports this, but Lemmy <0.19 uses this exclusively for DMs
   */
  async sendChatMessage(id: string, target: Actor, content: ChatMessage) {
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    await this.ctx.sendActivity(
      { identifier: USER_IDENTIFIER },
      [target],
      new Create({
        id: new URL("#create", this.ctx.getObjectUri(ChatMessage, { id })),
        actor: sender,
        to: target,
        object: content,
      }),
      { fanout: "skip" }
    );
  }

  private async deleteChatMessage(id: string, target: Actor) {
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    await this.ctx.sendActivity(
      { identifier: USER_IDENTIFIER },
      [target],
      new Delete({
        id: new URL("#delete", this.ctx.getObjectUri(ChatMessage, { id })),
        object: this.ctx.getObjectUri(ChatMessage, { id }),
        actor: sender,
        to: target,
      })
    );
  }

  /**
   * Send a private Note message targeted at Actor
   *
   * Most fediverse software supports this
   */
  async sendNote(id: string, target: Actor, content: Note) {
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    await this.ctx.sendActivity(
      { identifier: USER_IDENTIFIER },
      [target],
      new Create({
        id: new URL("#create", this.ctx.getObjectUri(Note, { id })),
        actor: sender,
        to: target,
        object: content,
      }),
      { fanout: "skip" }
    );
  }

  private async deleteNote(id: string, target: Actor) {
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    await this.ctx.sendActivity(
      { identifier: USER_IDENTIFIER },
      [target],
      new Delete({
        id: new URL("#delete", this.ctx.getObjectUri(Note, { id })),
        object: new Tombstone({
          id: this.ctx.getObjectUri(Note, { id }),
        }),
        actor: sender,
        to: target,
      })
    );
  }

  build(type: "ChatMessage", opts: BuildObjectOpts): ChatMessage;
  build(type: "Note", opts: BuildObjectOpts): Note;
  build(type: "ChatMessage" | "Note", opts: BuildObjectOpts): unknown {
    if (!type) throw new Error();

    const { id, one_time_code, target, createdAt } = opts;
    const sender = this.ctx.getActorUri(USER_IDENTIFIER);

    const content = {
      content: `Code: ${one_time_code}

Do not share this code. This code is used to identify you.`,
    };

    switch (type) {
      case "ChatMessage":
        return new ChatMessage({
          id: this.ctx.getObjectUri(ChatMessage, { id }),
          attribution: sender,
          to: target.id!,
          published: Temporal.Instant.from(createdAt.toISOString()),
          ...content,
        });
      case "Note":
        return new Note({
          id: this.ctx.getObjectUri(Note, { id }),
          attribution: sender,
          to: target.id!,
          published: Temporal.Instant.from(createdAt.toISOString()),
          tags: [
            new Mention({
              href: target.id,
              name: target.id!.toString(),
            }),
          ],
          ...content,
        });
    }
  }
}
export const APub =
  process.env.NODE_ENV === "development" ? APubStub : APubLive;