Loading backend/src/lib/apub/utils.live.ts 0 → 100644 +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, }); } } } backend/src/lib/apub/utils.stub.ts +20 −8 Original line number Diff line number Diff line Loading @@ -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); } Loading @@ -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() { Loading @@ -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) {} Loading @@ -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 backend/src/lib/apub/utils.ts +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; Loading
backend/src/lib/apub/utils.live.ts 0 → 100644 +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, }); } } }
backend/src/lib/apub/utils.stub.ts +20 −8 Original line number Diff line number Diff line Loading @@ -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); } Loading @@ -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() { Loading @@ -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) {} Loading @@ -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
backend/src/lib/apub/utils.ts +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;