summary refs log tree commit diff
path: root/src/activitypub/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/activitypub/util')
-rw-r--r--src/activitypub/util/APError.ts3
-rw-r--r--src/activitypub/util/OrderedCollection.ts50
-rw-r--r--src/activitypub/util/fetch.ts8
-rw-r--r--src/activitypub/util/index.ts4
-rw-r--r--src/activitypub/util/transforms/index.ts196
5 files changed, 261 insertions, 0 deletions
diff --git a/src/activitypub/util/APError.ts b/src/activitypub/util/APError.ts
new file mode 100644

index 00000000..184ddc32 --- /dev/null +++ b/src/activitypub/util/APError.ts
@@ -0,0 +1,3 @@ +import { HTTPError } from "lambert-server"; + +export class APError extends HTTPError {} diff --git a/src/activitypub/util/OrderedCollection.ts b/src/activitypub/util/OrderedCollection.ts new file mode 100644
index 00000000..83cf9bd9 --- /dev/null +++ b/src/activitypub/util/OrderedCollection.ts
@@ -0,0 +1,50 @@ +import { + APObject, + APOrderedCollection, + OrderedCollectionItemsField, +} from "activitypub-types"; +import { Request } from "express"; + +interface ActivityPubable { + toAP(): APObject; +} + +interface CorrectOrderedCollection extends APOrderedCollection { + orderedItems?: OrderedCollectionItemsField[]; +} + +export const makeOrderedCollection = async <T extends ActivityPubable>( + req: Request, + id: string, + getTotalElements: () => Promise<number>, + getElements: (before?: string, after?: string) => Promise<T[]>, +): Promise<CorrectOrderedCollection> => { + const { page, min_id, max_id } = req.query; + + if (!page) + return { + "@context": "https://www.w3.org/ns/activitystreams", + id: id, + type: "OrderedCollection", + totalItems: await getTotalElements(), + first: `${id}?page=true`, + last: `${id}?page=true&min_id=0`, + }; + + const after = min_id ? `${min_id}` : undefined; + const before = max_id ? `${max_id}` : undefined; + + const elems = await getElements(before, after); + + const items = elems.map((elem) => elem.toAP()); + + return { + "@context": "https://www.w3.org/ns/activitystreams", + id: `${id}?page=true`, + type: "OrderedCollection", + first: `${id}?page=true`, + last: `${id}?page=true&min_id=0`, + totalItems: await getTotalElements(), + orderedItems: items, + }; +}; diff --git a/src/activitypub/util/fetch.ts b/src/activitypub/util/fetch.ts new file mode 100644
index 00000000..07acdc45 --- /dev/null +++ b/src/activitypub/util/fetch.ts
@@ -0,0 +1,8 @@ +import { DEFAULT_FETCH_OPTIONS } from "@spacebar/api"; +import { OrmUtils } from "@spacebar/util"; + +export const fetchOpts = OrmUtils.mergeDeep(DEFAULT_FETCH_OPTIONS, { + headers: { + Accept: "application/activity+json", + }, +}); diff --git a/src/activitypub/util/index.ts b/src/activitypub/util/index.ts new file mode 100644
index 00000000..fe142a64 --- /dev/null +++ b/src/activitypub/util/index.ts
@@ -0,0 +1,4 @@ +export * from "./APError"; +export * from "./OrderedCollection"; +export * from "./fetch"; +export * from "./transforms/index"; diff --git a/src/activitypub/util/transforms/index.ts b/src/activitypub/util/transforms/index.ts new file mode 100644
index 00000000..7333233e --- /dev/null +++ b/src/activitypub/util/transforms/index.ts
@@ -0,0 +1,196 @@ +import { APError, fetchOpts } from "@spacebar/ap"; +import { + Channel, + Config, + DmChannelDTO, + Member, + Message, + Snowflake, + User, + UserSettings, + WebfingerResponse, +} from "@spacebar/util"; +import { APNote, APPerson, AnyAPObject } from "activitypub-types"; +import fetch from "node-fetch"; +import { ProxyAgent } from "proxy-agent"; +import TurndownService from "turndown"; + +const hasAPContext = (data: object) => { + if (!("@context" in data)) return false; + const context = data["@context"]; + const activitystreams = "https://www.w3.org/ns/activitystreams"; + if (Array.isArray(context)) + return context.find((x) => x == activitystreams); + return context == activitystreams; +}; + +export const resolveAPObject = async <T>(data: string | T): Promise<T> => { + // we were already given an AP object + if (typeof data != "string") return data; + + const agent = new ProxyAgent(); + const ret = await fetch(data, { + ...fetchOpts, + agent, + }); + + const json = await ret.json(); + + if (!hasAPContext(json)) throw new APError("Object is not APObject"); + + return json; +}; + +export const resolveWebfinger = async ( + lookup: string, +): Promise<AnyAPObject> => { + let domain: string, user: string; + if (lookup.includes("@")) { + // lookup a @handle + + if (lookup[0] == "@") lookup = lookup.slice(1); + [domain, user] = lookup.split("@"); + } else { + // lookup was a URL ( hopefully ) + const url = new URL(lookup); + domain = url.hostname; + user = url.pathname.split("/").reverse()[0]; + } + + const agent = new ProxyAgent(); + const wellknown = (await fetch( + `https://${domain}/.well-known/webfinger?resource=${lookup}`, + { + agent, + ...fetchOpts, + }, + ).then((x) => x.json())) as WebfingerResponse; + + const link = wellknown.links.find((x) => x.rel == "self"); + if (!link) throw new APError(".well-known did not contain rel=self link"); + + return await resolveAPObject<AnyAPObject>(link.href); +}; + +export const messageFromAP = async (data: APNote): Promise<Message> => { + if (!data.id) throw new APError("Message must have ID"); + if (data.type != "Note") throw new APError("Message must be Note"); + + if (!data.attributedTo) + throw new APError("Message must have author (attributedTo)"); + const attrib = await resolveAPObject( + Array.isArray(data.attributedTo) + ? data.attributedTo[0] // hmm + : data.attributedTo, + ); + + if (!APObjectIsPerson(attrib)) + throw new APError("Message attributedTo must be Person"); + + const user = await userFromAP(attrib); + + const to = Array.isArray(data.to) + ? data.to.filter((x) => + typeof x == "string" + ? x.includes("channel") || x.includes("user") + : false, + )[0] + : data.to; + if (!to || typeof to != "string") + throw new APError("Message not deliverable"); + + // TODO: use a regex + + let channel: Channel | DmChannelDTO; + const to_id = to.split("/").reverse()[0]; + if (to.includes("user")) { + // this is a DM channel + const toUser = await User.findOneOrFail({ where: { id: to_id } }); + + // Channel.createDMCHannel does a .save() so the author must be present + await user.save(); + + // const cache = await Channel.findOne({ where: { recipients: []}}) + + channel = await Channel.createDMChannel( + [toUser.id, user.id], + toUser.id, + ); + } else { + channel = await Channel.findOneOrFail({ + where: { id: to_id }, + relations: { guild: true }, + }); + } + + const member = + channel instanceof Channel + ? await Member.findOneOrFail({ + where: { id: user.id, guild_id: channel.guild!.id }, + }) + : undefined; + + return Message.create({ + id: Snowflake.generate(), + federatedId: data.id, + content: new TurndownService().turndown(data.content), + timestamp: data.published, + author: user, + guild: channel instanceof Channel ? channel.guild : undefined, + member, + channel_id: channel.id, + + type: 0, + sticker_items: [], + attachments: [], + embeds: [], + reactions: [], + mentions: [], + mention_roles: [], + mention_channels: [], + }); +}; + +export const APObjectIsPerson = (object: AnyAPObject): object is APPerson => { + return object.type == "Person"; +}; + +export const userFromAP = async (data: APPerson): Promise<User> => { + if (!data.id) throw new APError("User must have ID"); + + const url = new URL(data.id); + const email = `${url.pathname.split("/").reverse()[0]}@${url.hostname}`; + + // don't like this + // the caching should probably be done elsewhere + // this function should only be for converting AP to SB (ideally) + const cache = await User.findOne({ + where: { federatedId: url.toString() }, + }); + if (cache) return cache; + + return User.create({ + federatedId: url.toString(), + username: data.preferredUsername, + discriminator: url.hostname, + bio: new TurndownService().turndown(data.summary), + email, + data: { + hash: "#", + valid_tokens_since: new Date(), + }, + extended_settings: "{}", + settings: UserSettings.create(), + publicKey: "", + privateKey: "", + premium: false, + + premium_since: Config.get().defaults.user.premium + ? new Date() + : undefined, + rights: Config.get().register.defaultRights, + premium_type: Config.get().defaults.user.premiumType ?? 0, + verified: Config.get().defaults.user.verified ?? true, + created_at: new Date(), + }); +};