From 432750c37ba99495c3eec349f9a58363837f85ab Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Tue, 15 Aug 2023 13:26:32 +0000 Subject: [PATCH] Move AP methods to own file --- package-lock.json | Bin 292859 -> 293730 bytes package.json | 2 + .../routes/channel/#channel_id/inbox.ts | 7 +- src/activitypub/routes/user/inbox.ts | 12 ++ .../routes/{user.ts => user/index.ts} | 0 src/activitypub/util/APError.ts | 3 + src/activitypub/util/index.ts | 2 + src/activitypub/util/transforms/Message.ts | 141 ++++++++++++++++++ src/activitypub/util/transforms/index.ts | 1 + src/util/entities/Message.ts | 75 +--------- src/util/entities/User.ts | 45 +----- 11 files changed, 171 insertions(+), 117 deletions(-) create mode 100644 src/activitypub/routes/user/inbox.ts rename src/activitypub/routes/{user.ts => user/index.ts} (100%) create mode 100644 src/activitypub/util/APError.ts create mode 100644 src/activitypub/util/transforms/Message.ts create mode 100644 src/activitypub/util/transforms/index.ts diff --git a/package-lock.json b/package-lock.json index ae2f124de4e81e6e282445388083439222c6dfad..8f165bc5f3ec05dd633dfaa4b7a56f11ce0adcb9 100644 GIT binary patch delta 504 zcmezUUhvU3!3`5x1xt(aQu535l&ln#;>`67^^7)mu>N4;D?t`Foh-;M+}yyiy@7)< z!kz;rH~r!dM)zrQjO@ZN5q({K7<0PePe#>HKMThclT_n;uZj%IJWHp{s0z#Eq*Q$a zU*qgTmjeHgT%+=;EJNe+6qnEnmw>FatepICPp`zn(x|F%SHtAU5J!)KEPoH{tv1Q}T0xe4tAp3|*t? z`L-;|p~hh;zWTutPEip?`MJrKUT&6o{sGPYj`qhuY(Ln1Gmhdpr-z8-?w~dMxjK E03`tvy8r+H diff --git a/package.json b/package.json index e13c03f1b..9d86f0870 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@types/nodemailer": "^6.4.9", "@types/probe-image-size": "^7.2.0", "@types/sharp": "^0.31.1", + "@types/turndown": "^5.0.1", "@types/ws": "^8.5.5", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -105,6 +106,7 @@ "reflect-metadata": "^0.1.13", "ts-node": "^10.9.1", "tslib": "^2.6.1", + "turndown": "^7.1.2", "typeorm": "^0.3.17", "typescript-json-schema": "^0.50.1", "wretch": "^2.6.0", diff --git a/src/activitypub/routes/channel/#channel_id/inbox.ts b/src/activitypub/routes/channel/#channel_id/inbox.ts index ee7f45191..2dd361430 100644 --- a/src/activitypub/routes/channel/#channel_id/inbox.ts +++ b/src/activitypub/routes/channel/#channel_id/inbox.ts @@ -1,3 +1,4 @@ +import { messageFromAP } from "@spacebar/ap"; import { route } from "@spacebar/api"; import { Message, emitEvent } from "@spacebar/util"; import { Router } from "express"; @@ -11,7 +12,11 @@ router.post("/", route({}), async (req, res) => { if (body.type != "Create") throw new HTTPError("not implemented"); - const message = await Message.fromAP(body.object); + const message = await messageFromAP(body.object); + + if ((await Message.count({ where: { id: message.id } })) != 0) + return res.status(200); + await message.save(); await emitEvent({ diff --git a/src/activitypub/routes/user/inbox.ts b/src/activitypub/routes/user/inbox.ts new file mode 100644 index 000000000..2065d7f21 --- /dev/null +++ b/src/activitypub/routes/user/inbox.ts @@ -0,0 +1,12 @@ +import { route } from "@spacebar/api"; +import { Router } from "express"; +import { HTTPError } from "lambert-server"; + +const router = Router(); +export default router; + +router.post("/", route({}), async (req, res) => { + const body = req.body; + + if (body.type != "Create") throw new HTTPError("not implemented"); +}); diff --git a/src/activitypub/routes/user.ts b/src/activitypub/routes/user/index.ts similarity index 100% rename from src/activitypub/routes/user.ts rename to src/activitypub/routes/user/index.ts diff --git a/src/activitypub/util/APError.ts b/src/activitypub/util/APError.ts new file mode 100644 index 000000000..184ddc323 --- /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/index.ts b/src/activitypub/util/index.ts index 7204e7aef..767e75d95 100644 --- a/src/activitypub/util/index.ts +++ b/src/activitypub/util/index.ts @@ -1 +1,3 @@ +export * from "./APError"; export * from "./OrderedCollection"; +export * from "./transforms/index"; diff --git a/src/activitypub/util/transforms/Message.ts b/src/activitypub/util/transforms/Message.ts new file mode 100644 index 000000000..3669121ca --- /dev/null +++ b/src/activitypub/util/transforms/Message.ts @@ -0,0 +1,141 @@ +import { APError } from "@spacebar/ap"; +import { DEFAULT_FETCH_OPTIONS } from "@spacebar/api"; +import { + Channel, + Config, + Member, + Message, + OrmUtils, + Snowflake, + User, + UserSettings, +} 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 fetchOpts = OrmUtils.mergeDeep(DEFAULT_FETCH_OPTIONS, { + headers: { + Accept: "application/activity+json", + }, +}); + +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 (data: string | T): Promise => { + // 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 messageFromAP = async (data: APNote): Promise => { + if (!data.id) throw new APError("Message must have ID"); + if (data.type != "Note") throw new APError("Message must be Note"); + + const to = Array.isArray(data.to) + ? data.to.filter((x) => + typeof x == "string" ? x.includes("channel") : false, + )[0] + : data.to; + if (!to || typeof to != "string") + throw new APError("Message not deliverable"); + + // TODO: use a regex + const channel_id = to.split("/").reverse()[0]; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + relations: { guild: true }, + }); + + 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 member = channel.guild + ? await Member.findOneOrFail({ + where: { id: user.id, guild_id: channel.guild.id }, + }) + : undefined; + + return Message.create({ + id: data.id, + content: new TurndownService().turndown(data.content), + timestamp: data.published, + author: user, + guild: channel.guild, + member, + channel, + + 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 => { + 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}`; + + return User.create({ + id: Snowflake.generate(), + 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(), + }); +}; diff --git a/src/activitypub/util/transforms/index.ts b/src/activitypub/util/transforms/index.ts new file mode 100644 index 000000000..6596816a1 --- /dev/null +++ b/src/activitypub/util/transforms/index.ts @@ -0,0 +1 @@ +export * from "./Message"; diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts index 65dfa926b..bbbb2ac18 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts @@ -16,13 +16,7 @@ along with this program. If not, see . */ -import type { - APAnnounce, - APNote, - APPerson, - AnyAPObject, -} from "activitypub-types"; -import fetch from "node-fetch"; +import type { APAnnounce, APNote } from "activitypub-types"; import { Column, CreateDateColumn, @@ -35,7 +29,7 @@ import { OneToMany, RelationId, } from "typeorm"; -import { Config, Snowflake } from ".."; +import { Config } from ".."; import { InteractionType } from "../interfaces/Interaction"; import { Application } from "./Application"; import { Attachment } from "./Attachment"; @@ -262,6 +256,7 @@ export class Message extends BaseClass { }; } + // TODO: move to AP module toAP(): APNote { const { webDomain } = Config.get().federation; @@ -275,70 +270,6 @@ export class Message extends BaseClass { content: this.content, }; } - - static async fromAP(data: APNote): Promise { - if (!data.attributedTo) - throw new Error("sb Message must have author (attributedTo)"); - - let attrib = Array.isArray(data.attributedTo) - ? data.attributedTo[0] - : data.attributedTo; - if (typeof attrib == "string") { - // fetch it - attrib = (await fetch(attrib, { - headers: { Accept: "application/activity+json" }, - }).then((x) => x.json())) as AnyAPObject; - } - - if (attrib.type != "Person") - throw new Error("only Person can be author of sb Message"); //hm - - let to = data.to; - - if (Array.isArray(to)) - to = to.filter((x) => { - if (typeof x == "string") return x.includes("channel"); - return false; - })[0]; - - if (!to) throw new Error("not deliverable"); - - const channel_id = (to as string).split("/").reverse()[0]; - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - relations: { guild: true }, - }); - - const user = await User.fromAP(attrib as APPerson); - let member; - if ( - (await Member.count({ - where: { id: user.id, guild_id: channel.guild_id }, - })) == 0 - ) - member = await Member.addToGuild(user.id, channel.guild.id); - - return Message.create({ - id: Snowflake.generate(), - author: user, - member, - content: data.content, // convert html to markdown - timestamp: data.published, - channel, - guild: channel.guild, - - sticker_items: [], - guild_id: channel.guild_id, - attachments: [], - embeds: [], - reactions: [], - type: 0, - mentions: [], - mention_roles: [], - mention_channels: [], - }); - } } export interface MessageComponent { diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts index 25902ba7c..f92136931 100644 --- a/src/util/entities/User.ts +++ b/src/util/entities/User.ts @@ -37,7 +37,6 @@ import { Session } from "./Session"; import { UserSettings } from "./UserSettings"; import crypto from "crypto"; -import fetch from "node-fetch"; import { promisify } from "util"; const generateKeyPair = promisify(crypto.generateKeyPair); @@ -293,6 +292,7 @@ export class User extends BaseClass { return user as UserPrivate; } + // TODO: move to AP module toAP(): APPersonButMore { const { webDomain } = Config.get().federation; @@ -323,49 +323,6 @@ export class User extends BaseClass { }; } - static async fromAP(data: APPerson | string): Promise { - if (typeof data == "string") { - data = (await fetch(data, { - headers: { Accept: "application/json" }, - }).then((x) => x.json())) as APPerson; - } - - const cache = await User.findOne({ - where: { - email: `${data.preferredUsername}@${ - new URL(data.id!).hostname - }`, - }, - }); - if (cache) return cache; - - return User.create({ - id: Snowflake.generate(), // hm - username: data.preferredUsername, - discriminator: new URL(data.id!).hostname, - premium: false, - bio: data.summary, // TODO: convert to markdown - - email: `${data.preferredUsername}@${new URL(data.id!).hostname}`, - data: { - hash: "#", - valid_tokens_since: new Date(), - }, - extended_settings: "{}", - settings: UserSettings.create(), - publicKey: "", - privateKey: "", - - 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(), - }).save(); - } - static async getPublicUser(user_id: string, opts?: FindOneOptions) { return await User.findOneOrFail({ where: { id: user_id },