summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/activitypub/Server.ts83
-rw-r--r--src/activitypub/index.ts2
-rw-r--r--src/activitypub/listener/index.ts136
-rw-r--r--src/activitypub/routes/channel/#channel_id/followers.ts28
-rw-r--r--src/activitypub/routes/channel/#channel_id/inbox.ts33
-rw-r--r--src/activitypub/routes/channel/#channel_id/index.ts14
-rw-r--r--src/activitypub/routes/channel/#channel_id/messages/#message_id/index.ts29
-rw-r--r--src/activitypub/routes/channel/#channel_id/outbox.ts50
-rw-r--r--src/activitypub/routes/messages/index.ts14
-rw-r--r--src/activitypub/routes/user/#user_id/inbox.ts33
-rw-r--r--src/activitypub/routes/user/#user_id/index.ts14
-rw-r--r--src/activitypub/start.ts7
-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
-rw-r--r--src/activitypub/well-known/host-meta.ts20
-rw-r--r--src/activitypub/well-known/webfinger.ts69
-rw-r--r--src/bundle/Server.ts18
-rw-r--r--src/bundle/index.ts5
-rw-r--r--src/util/config/Config.ts2
-rw-r--r--src/util/config/types/FederationConfiguration.ts5
-rw-r--r--src/util/entities/Channel.ts67
-rw-r--r--src/util/entities/Member.ts46
-rw-r--r--src/util/entities/Message.ts63
-rw-r--r--src/util/entities/User.ts71
-rw-r--r--src/util/schemas/MessageAcknowledgeSchema.ts3
-rw-r--r--src/util/schemas/responses/WebfingerResponse.ts12
-rw-r--r--src/util/schemas/responses/index.ts3
-rw-r--r--src/util/util/Event.ts4
31 files changed, 1051 insertions, 41 deletions
diff --git a/src/activitypub/Server.ts b/src/activitypub/Server.ts
new file mode 100644

index 00000000..97deb137 --- /dev/null +++ b/src/activitypub/Server.ts
@@ -0,0 +1,83 @@ +import { BodyParser, CORS, ErrorHandler } from "@spacebar/api"; +import { + Config, + JSONReplacer, + initDatabase, + registerRoutes, +} from "@spacebar/util"; +import bodyParser from "body-parser"; +import { Request, Response, Router } from "express"; +import { Server, ServerOptions } from "lambert-server"; +import path from "path"; +import { setupListener } from "./listener"; +import hostMeta from "./well-known/host-meta"; +import webfinger from "./well-known/webfinger"; + +export class APServer extends Server { + public declare options: ServerOptions; + + constructor(opts?: Partial<ServerOptions>) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + super({ ...opts, errorHandler: false, jsonBody: false }); + } + + async start() { + await initDatabase(); + await Config.init(); + setupListener(); + + this.app.set("json replacer", JSONReplacer); + + this.app.use(CORS); + this.app.use( + BodyParser({ + inflate: true, + limit: "10mb", + type: "application/activity+json", + }), + ); + this.app.use(bodyParser.urlencoded({ extended: true })); + + const api = Router(); + const app = this.app; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // lambert server is lame + this.app = api; + + this.routes = await registerRoutes( + this, + path.join(__dirname, "routes", "/"), + ); + + api.use("*", (req: Request, res: Response) => { + res.status(404).json({ + message: "404 endpoint not found", + code: 0, + }); + }); + + this.app = app; + + this.app.use("*", (req, res, next) => { + res.setHeader( + "Content-Type", + "application/activity+json; charset=utf-8", + ); + next(); + }); + this.app.use("/fed", api); + this.app.get("/fed", (req, res) => { + res.json({ ping: "pong" }); + }); + + this.app.use("/.well-known/webfinger", webfinger); + this.app.use("/.well-known/host-meta", hostMeta); + + this.app.use(ErrorHandler); + + return super.start(); + } +} diff --git a/src/activitypub/index.ts b/src/activitypub/index.ts new file mode 100644
index 00000000..9e2d8a3e --- /dev/null +++ b/src/activitypub/index.ts
@@ -0,0 +1,2 @@ +export * from "./Server"; +export * from "./util"; diff --git a/src/activitypub/listener/index.ts b/src/activitypub/listener/index.ts new file mode 100644
index 00000000..26c3e3e5 --- /dev/null +++ b/src/activitypub/listener/index.ts
@@ -0,0 +1,136 @@ +import { + APError, + APObjectIsPerson, + fetchOpts, + resolveWebfinger, +} from "@spacebar/ap"; +import { + Channel, + Config, + EVENTEnum, + Event, + Message, + MessageCreateEvent, + OrmUtils, + RabbitMQ, + User, + events, +} from "@spacebar/util"; +import crypto from "crypto"; +import fetch from "node-fetch"; + +const sendSignedMessage = async ( + inbox: string, + sender: `${"user" | "channel"}/${string}`, + message: object, + privateKey: string, +) => { + const digest = crypto + .createHash("sha256") + .update(JSON.stringify(message)) + .digest("base64"); + const signer = crypto.createSign("sha256"); + const now = new Date(); + + const url = new URL(inbox); + const inboxFrag = url.pathname; + const toSign = + `(request-target): post ${inboxFrag}\n` + + `host: ${url.hostname}\n` + + `date: ${now.toUTCString()}\n` + + `digest: SHA-256=${digest}`; + + signer.update(toSign); + signer.end(); + + const signature = signer.sign(privateKey); + const sig_b64 = signature.toString("base64"); + + const { webDomain } = Config.get().federation; + const header = + `keyId="https://${webDomain}/fed/${sender}",` + + `headers="(request-target) host date digest",` + + `signature=${sig_b64}`; + + return await fetch( + inbox, + OrmUtils.mergeDeep(fetchOpts, { + method: "POST", + body: message, + headers: { + Host: url.hostname, + Date: now.toUTCString(), + Digest: `SHA-256=${digest}`, + Signature: header, + }, + }), + ); +}; + +const onMessage = async (event: MessageCreateEvent) => { + const channel_id = event.channel_id; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + relations: { + recipients: { + user: true, + }, + }, + }); + if (channel.isDm()) { + const message = await Message.findOneOrFail({ + where: { id: event.data.id }, + }); + const apMessage = message.toCreateAP(); + + for (const recipient of channel.recipients || []) { + if (recipient.user.federatedId) { + const user = await resolveWebfinger(recipient.user.federatedId); + if (!APObjectIsPerson(user)) + throw new APError("Cannot deliver message"); + + if (!user.id) throw new APError("Receiver ID is null?"); + + apMessage.to = [user.id]; + + const sender = await User.findOneOrFail({ + where: { id: event.data.author_id }, + select: ["privateKey"], + }); + + if (typeof user.inbox != "string") + throw new APError("inbox must be URL"); + + console.log( + await sendSignedMessage( + user.inbox, + `user/${event.data.author_id}`, + message, + sender.privateKey, + ).then((x) => x.text()), + ); + } + } + } +}; + +type ListenerFunc = (event: Event) => Promise<void>; + +const listeners = { + MESSAGE_CREATE: onMessage, +} as Record<EVENTEnum, ListenerFunc>; + +export const setupListener = () => { + if (RabbitMQ.connection) + throw new APError("Activitypub module has not implemented RabbitMQ"); + + // for (const event in listeners) { + // // process.setMaxListeners(process.getMaxListeners() + 1); + // // process.addListener("message", (msg) => + // // listener(msg as ProcessEvent, event, listeners[event as EVENTEnum]), + // // ); + + events.setMaxListeners(events.getMaxListeners() + 1); + events.onAny((event, msg) => listeners[msg.event as EVENTEnum]?.(msg)); + // } +}; diff --git a/src/activitypub/routes/channel/#channel_id/followers.ts b/src/activitypub/routes/channel/#channel_id/followers.ts new file mode 100644
index 00000000..ac474160 --- /dev/null +++ b/src/activitypub/routes/channel/#channel_id/followers.ts
@@ -0,0 +1,28 @@ +import { makeOrderedCollection } from "@spacebar/ap"; +import { route } from "@spacebar/api"; +import { Config, Member } from "@spacebar/util"; +import { Router } from "express"; + +const router = Router(); +export default router; + +router.get("/", route({}), async (req, res) => { + // TODO auth + const { channel_id } = req.params; + + const { webDomain } = Config.get().federation; + + const ret = await makeOrderedCollection( + req, + `https://${webDomain}/fed/channel/${channel_id}/followers`, + () => + Member.count({ + where: { guild: { channels: { id: channel_id } } }, + }), + async (before, after) => { + return []; + }, + ); + + return res.json(ret); +}); diff --git a/src/activitypub/routes/channel/#channel_id/inbox.ts b/src/activitypub/routes/channel/#channel_id/inbox.ts new file mode 100644
index 00000000..89a990dc --- /dev/null +++ b/src/activitypub/routes/channel/#channel_id/inbox.ts
@@ -0,0 +1,33 @@ +import { messageFromAP } from "@spacebar/ap"; +import { route } from "@spacebar/api"; +import { Message, emitEvent } from "@spacebar/util"; +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"); + + const message = await messageFromAP(body.object); + + if ( + (await Message.count({ + where: { federatedId: message.federatedId }, + })) != 0 + ) + return res.status(200); + + await message.save(); + + await emitEvent({ + event: "MESSAGE_CREATE", + channel_id: message.channel_id, + data: message.toJSON(), + }); + + return res.status(200); +}); diff --git a/src/activitypub/routes/channel/#channel_id/index.ts b/src/activitypub/routes/channel/#channel_id/index.ts new file mode 100644
index 00000000..fd83563e --- /dev/null +++ b/src/activitypub/routes/channel/#channel_id/index.ts
@@ -0,0 +1,14 @@ +import { route } from "@spacebar/api"; +import { Channel } from "@spacebar/util"; +import { Request, Response, Router } from "express"; + +const router = Router(); +export default router; + +router.get("/", route({}), async (req: Request, res: Response) => { + const id = req.params.channel_id; + + const channel = await Channel.findOneOrFail({ where: { id } }); + + return res.json(channel.toAP()); +}); diff --git a/src/activitypub/routes/channel/#channel_id/messages/#message_id/index.ts b/src/activitypub/routes/channel/#channel_id/messages/#message_id/index.ts new file mode 100644
index 00000000..87bec308 --- /dev/null +++ b/src/activitypub/routes/channel/#channel_id/messages/#message_id/index.ts
@@ -0,0 +1,29 @@ +import { route } from "@spacebar/api"; +import { Config, Message } from "@spacebar/util"; +import { APAnnounce } from "activitypub-types"; +import { Request, Response, Router } from "express"; + +const router = Router(); +export default router; + +router.get("/", route({}), async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params; + + const message = await Message.findOneOrFail({ + where: { id: message_id, channel_id }, + relations: { author: true, guild: true }, + }); + const { webDomain } = Config.get().federation; + + const ret: APAnnounce = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `https://${webDomain}/fed/channel/${message.channel_id}/messages/${message.id}`, + type: "Announce", + actor: `https://${webDomain}/fed/user/${message.author_id}`, + published: message.timestamp, + to: ["https://www.w3.org/ns/activitystreams#Public"], // TODO + object: message.toAP(), + }; + + return res.json(ret); +}); diff --git a/src/activitypub/routes/channel/#channel_id/outbox.ts b/src/activitypub/routes/channel/#channel_id/outbox.ts new file mode 100644
index 00000000..e2e0e585 --- /dev/null +++ b/src/activitypub/routes/channel/#channel_id/outbox.ts
@@ -0,0 +1,50 @@ +import { makeOrderedCollection } from "@spacebar/ap"; +import { route } from "@spacebar/api"; +import { Config, Message, Snowflake } from "@spacebar/util"; +import { Router } from "express"; +import { FindManyOptions, FindOperator, LessThan, MoreThan } from "typeorm"; + +const router = Router(); +export default router; + +router.get("/", route({}), async (req, res) => { + // TODO: authentication + + const { channel_id } = req.params; + const { page, min_id, max_id } = req.query; + + const { webDomain } = Config.get().federation; + + const ret = await makeOrderedCollection( + req, + `https://${webDomain}/fed/channel/${channel_id}/outbox`, + () => Message.count({ where: { channel_id } }), + async (before, after) => { + const query: FindManyOptions<Message> & { + where: { id?: FindOperator<string> | FindOperator<string>[] }; + } = { + order: { timestamp: "DESC" }, + take: 20, + where: { channel_id: channel_id }, + relations: ["author"], + }; + + if (after) { + if (BigInt(after) > BigInt(Snowflake.generate())) return []; + query.where.id = MoreThan(after); + } else if (before) { + if (BigInt(before) > BigInt(Snowflake.generate())) return []; + query.where.id = LessThan(before); + } + + const messages = await Message.find(query); + + return messages.map((x) => ({ + ...x, + toAP: () => x.toAnnounceAP(), + })); + }, + ); + + return res.json(ret); +}); diff --git a/src/activitypub/routes/messages/index.ts b/src/activitypub/routes/messages/index.ts new file mode 100644
index 00000000..c764cdfa --- /dev/null +++ b/src/activitypub/routes/messages/index.ts
@@ -0,0 +1,14 @@ +import { route } from "@spacebar/api"; +import { Message } from "@spacebar/util"; +import { Router } from "express"; + +const router = Router(); +export default router; + +router.get("/:message_id", route({}), async (req, res) => { + const id = req.params.message_id; + + const message = await Message.findOneOrFail({ where: { id } }); + + return res.json(message.toAP()); +}); diff --git a/src/activitypub/routes/user/#user_id/inbox.ts b/src/activitypub/routes/user/#user_id/inbox.ts new file mode 100644
index 00000000..89a990dc --- /dev/null +++ b/src/activitypub/routes/user/#user_id/inbox.ts
@@ -0,0 +1,33 @@ +import { messageFromAP } from "@spacebar/ap"; +import { route } from "@spacebar/api"; +import { Message, emitEvent } from "@spacebar/util"; +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"); + + const message = await messageFromAP(body.object); + + if ( + (await Message.count({ + where: { federatedId: message.federatedId }, + })) != 0 + ) + return res.status(200); + + await message.save(); + + await emitEvent({ + event: "MESSAGE_CREATE", + channel_id: message.channel_id, + data: message.toJSON(), + }); + + return res.status(200); +}); diff --git a/src/activitypub/routes/user/#user_id/index.ts b/src/activitypub/routes/user/#user_id/index.ts new file mode 100644
index 00000000..d92663ff --- /dev/null +++ b/src/activitypub/routes/user/#user_id/index.ts
@@ -0,0 +1,14 @@ +import { route } from "@spacebar/api"; +import { User } from "@spacebar/util"; +import { Request, Response, Router } from "express"; + +const router = Router(); +export default router; + +router.get("/", route({}), async (req: Request, res: Response) => { + const id = req.params.user_id; + + const user = await User.findOneOrFail({ where: { id } }); + + return res.json(user.toAP()); +}); diff --git a/src/activitypub/start.ts b/src/activitypub/start.ts new file mode 100644
index 00000000..3f28fa42 --- /dev/null +++ b/src/activitypub/start.ts
@@ -0,0 +1,7 @@ +require("module-alias/register"); +import "dotenv/config"; +import { APServer } from "./Server"; + +const port = Number(process.env.PORT) || 3005; +const server = new APServer({ port }); +server.start().catch(console.error); 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(), + }); +}; diff --git a/src/activitypub/well-known/host-meta.ts b/src/activitypub/well-known/host-meta.ts new file mode 100644
index 00000000..2de2014c --- /dev/null +++ b/src/activitypub/well-known/host-meta.ts
@@ -0,0 +1,20 @@ +import { route } from "@spacebar/api"; +import { Config } from "@spacebar/util"; +import { Request, Response, Router } from "express"; + +const router = Router(); +export default router; + +router.get("/", route({}), async (req: Request, res: Response) => { + res.setHeader("Content-Type", "application/xrd+xml"); + + const { webDomain } = Config.get().federation; + + const ret = `<?xml version="1.0" encoding="UTF-8"?> + <XRD + xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"> + <Link rel="lrdd" type="application/xrd+xml" template="https://${webDomain}/.well-known/webfinger?resource={uri}"/> + </XRD>`; + + return res.send(ret); +}); diff --git a/src/activitypub/well-known/webfinger.ts b/src/activitypub/well-known/webfinger.ts new file mode 100644
index 00000000..d68666e0 --- /dev/null +++ b/src/activitypub/well-known/webfinger.ts
@@ -0,0 +1,69 @@ +import { route } from "@spacebar/api"; +import { Channel, Config, User, WebfingerResponse } from "@spacebar/util"; +import { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server"; + +const router = Router(); +export default router; + +router.get( + "/", + route({ + query: { + resource: { + type: "string", + description: "Resource to locate", + }, + }, + responses: { + 200: { + body: "WebfingerResponse", + }, + }, + }), + async (req: Request, res: Response<WebfingerResponse>) => { + let resource = req.query.resource as string | undefined; + if (!resource) throw new HTTPError("Must specify resource"); + + // we know what you mean, bro + resource = resource.replace("acct:", ""); + + if (resource[0] == "@") resource = resource.slice(1); + const [resourceId, resourceDomain] = resource.split("@"); + + const { webDomain } = Config.get().federation; + if (resourceDomain != webDomain) + throw new HTTPError("Resource could not be found", 404); + + const found = + (await User.findOne({ + where: { id: resourceId }, + select: ["id"], + })) || + (await Channel.findOne({ + where: { id: resourceId }, + select: ["id"], + })); + + if (!found) throw new HTTPError("Resource could not be found", 404); + + const type = found instanceof Channel ? "channel" : "user"; + + res.setHeader("Content-Type", "application/jrd+json; charset=utf-8"); + return res.json({ + subject: `acct:${resourceId}@${webDomain}`, // mastodon always returns acct so might as well + aliases: [`https://${webDomain}/fed/${type}/${resourceId}`], + links: [ + { + rel: "self", + type: "application/activity+json", + href: `https://${webDomain}/fed/${type}/${resourceId}`, + }, + // { + // rel: "http://ostatus.org/schema/1.0/subscribe", + // href: `"https://${webDomain}/fed/authorize-follow?acct={uri}"`, + // }, + ], + }); + }, +); diff --git a/src/bundle/Server.ts b/src/bundle/Server.ts
index d281120d..d5e2d6de 100644 --- a/src/bundle/Server.ts +++ b/src/bundle/Server.ts
@@ -19,13 +19,14 @@ process.on("unhandledRejection", console.error); process.on("uncaughtException", console.error); -import http from "http"; +import { APServer } from "@spacebar/ap"; import * as Api from "@spacebar/api"; -import * as Gateway from "@spacebar/gateway"; import { CDNServer } from "@spacebar/cdn"; +import * as Gateway from "@spacebar/gateway"; +import { Config, Sentry, initDatabase } from "@spacebar/util"; import express from "express"; -import { green, bold } from "picocolors"; -import { Config, initDatabase, Sentry } from "@spacebar/util"; +import http from "http"; +import { bold, green } from "picocolors"; const app = express(); const server = http.createServer(); @@ -36,12 +37,14 @@ server.on("request", app); const api = new Api.SpacebarServer({ server, port, production, app }); const cdn = new CDNServer({ server, port, production, app }); const gateway = new Gateway.Server({ server, port, production }); +const activitypub = new APServer({ server, port, production, app }); process.on("SIGTERM", async () => { console.log("Shutting down due to SIGTERM"); await gateway.stop(); await cdn.stop(); await api.stop(); + activitypub.stop(); server.close(); Sentry.close(); }); @@ -54,7 +57,12 @@ async function main() { await new Promise((resolve) => server.listen({ port }, () => resolve(undefined)), ); - await Promise.all([api.start(), cdn.start(), gateway.start()]); + await Promise.all([ + api.start(), + cdn.start(), + gateway.start(), + activitypub.start(), + ]); Sentry.errorHandler(app); diff --git a/src/bundle/index.ts b/src/bundle/index.ts
index c6af4f00..8b1c9429 100644 --- a/src/bundle/index.ts +++ b/src/bundle/index.ts
@@ -16,7 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ +export * from "@spacebar/ap"; export * from "@spacebar/api"; -export * from "@spacebar/util"; -export * from "@spacebar/gateway"; export * from "@spacebar/cdn"; +export * from "@spacebar/gateway"; +export * from "@spacebar/util"; diff --git a/src/util/config/Config.ts b/src/util/config/Config.ts
index 90b98b7a..0b3a4152 100644 --- a/src/util/config/Config.ts +++ b/src/util/config/Config.ts
@@ -38,6 +38,7 @@ import { SentryConfiguration, TemplateConfiguration, } from "../config"; +import { FederationConfiguration } from "./types/FederationConfiguration"; export class ConfigValue { gateway: EndpointConfiguration = new EndpointConfiguration(); @@ -61,4 +62,5 @@ export class ConfigValue { email: EmailConfiguration = new EmailConfiguration(); passwordReset: PasswordResetConfiguration = new PasswordResetConfiguration(); + federation = new FederationConfiguration(); } diff --git a/src/util/config/types/FederationConfiguration.ts b/src/util/config/types/FederationConfiguration.ts new file mode 100644
index 00000000..b04388fd --- /dev/null +++ b/src/util/config/types/FederationConfiguration.ts
@@ -0,0 +1,5 @@ +export class FederationConfiguration { + enabled: boolean = false; + localDomain: string | null = null; + webDomain: string | null = null; +} diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts
index 9f7041d4..0ccabd62 100644 --- a/src/util/entities/Channel.ts +++ b/src/util/entities/Channel.ts
@@ -28,6 +28,7 @@ import { import { DmChannelDTO } from "../dtos"; import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces"; import { + Config, InvisibleCharacters, Snowflake, containsAll, @@ -41,10 +42,14 @@ import { Invite } from "./Invite"; import { Message } from "./Message"; import { ReadState } from "./ReadState"; import { Recipient } from "./Recipient"; -import { PublicUserProjection, User } from "./User"; +import { APPersonButMore, PublicUserProjection, User } from "./User"; import { VoiceState } from "./VoiceState"; import { Webhook } from "./Webhook"; +import crypto from "crypto"; +import { promisify } from "util"; +const generateKeyPair = promisify(crypto.generateKeyPair); + export enum ChannelType { GUILD_TEXT = 0, // a text channel within a guild DM = 1, // a direct message between users @@ -193,6 +198,12 @@ export class Channel extends BaseClass { @Column() default_thread_rate_limit_per_user: number = 0; + @Column() + publicKey: string; + + @Column() + privateKey: string; + // TODO: DM channel static async createChannel( channel: Partial<Channel>, @@ -303,6 +314,21 @@ export class Channel extends BaseClass { : channel.position) || 0, }; + const { publicKey, privateKey } = await generateKeyPair("rsa", { + modulusLength: 4096, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }); + + channel.publicKey = publicKey; + channel.privateKey = privateKey; + const ret = Channel.create(channel); await Promise.all([ @@ -362,6 +388,18 @@ export class Channel extends BaseClass { if (channel == null) { name = trimSpecial(name); + const { publicKey, privateKey } = await generateKeyPair("rsa", { + modulusLength: 4096, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }); + channel = await Channel.create({ name, type, @@ -378,6 +416,8 @@ export class Channel extends BaseClass { }), ), nsfw: false, + publicKey, + privateKey, }).save(); } @@ -483,6 +523,31 @@ export class Channel extends BaseClass { owner_id: this.owner_id || undefined, }; } + + toAP(): APPersonButMore { + const { webDomain } = Config.get().federation; + + return { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Group", + id: `https://${webDomain}/fed/channel/${this.id}`, + name: this.name, + preferredUsername: this.id, + summary: this.topic, + icon: undefined, + discoverable: true, + + publicKey: { + id: `https://${webDomain}/fed/user/${this.id}#main-key`, + owner: `https://${webDomain}/fed/user/${this.id}`, + publicKeyPem: this.publicKey, + }, + + inbox: `https://${webDomain}/fed/channel/${this.id}/inbox`, + outbox: `https://${webDomain}/fed/channel/${this.id}/outbox`, + followers: `https://${webDomain}/fed/channel/${this.id}/followers`, + }; + } } export interface ChannelPermissionOverwrite { diff --git a/src/util/entities/Member.ts b/src/util/entities/Member.ts
index 0535313e..16b18ab1 100644 --- a/src/util/entities/Member.ts +++ b/src/util/entities/Member.ts
@@ -366,28 +366,30 @@ export class Member extends BaseClassWithoutId { bio: "", }; + const ret = Member.create({ + ...member, + roles: [Role.create({ id: guild_id })], + // read_state: {}, + settings: { + guild_id: null, + mute_config: null, + mute_scheduled_events: false, + flags: 0, + hide_muted_channels: false, + notify_highlights: 0, + channel_overrides: {}, + message_notifications: 0, + mobile_push: true, + muted: false, + suppress_everyone: false, + suppress_roles: false, + version: 0, + }, + // Member.save is needed because else the roles relations wouldn't be updated + }); + await Promise.all([ - Member.create({ - ...member, - roles: [Role.create({ id: guild_id })], - // read_state: {}, - settings: { - guild_id: null, - mute_config: null, - mute_scheduled_events: false, - flags: 0, - hide_muted_channels: false, - notify_highlights: 0, - channel_overrides: {}, - message_notifications: 0, - mobile_push: true, - muted: false, - suppress_everyone: false, - suppress_roles: false, - version: 0, - }, - // Member.save is needed because else the roles relations wouldn't be updated - }).save(), + ret.save(), Guild.increment({ id: guild_id }, "member_count", 1), emitEvent({ event: "GUILD_MEMBER_ADD", @@ -444,6 +446,8 @@ export class Member extends BaseClassWithoutId { } as MessageCreateEvent), ]); } + + return ret; } toPublicMember() { diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts
index 3598d29f..3bf3b9d0 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts
@@ -16,12 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { User } from "./User"; -import { Member } from "./Member"; -import { Role } from "./Role"; -import { Channel } from "./Channel"; -import { InteractionType } from "../interfaces/Interaction"; -import { Application } from "./Application"; +import type { APAnnounce, APCreate, APNote } from "activitypub-types"; import { Column, CreateDateColumn, @@ -34,11 +29,18 @@ import { OneToMany, RelationId, } from "typeorm"; +import { Config } from ".."; +import { InteractionType } from "../interfaces/Interaction"; +import { Application } from "./Application"; +import { Attachment } from "./Attachment"; import { BaseClass } from "./BaseClass"; +import { Channel } from "./Channel"; import { Guild } from "./Guild"; -import { Webhook } from "./Webhook"; +import { Member } from "./Member"; +import { Role } from "./Role"; import { Sticker } from "./Sticker"; -import { Attachment } from "./Attachment"; +import { User } from "./User"; +import { Webhook } from "./Webhook"; export enum MessageType { DEFAULT = 0, @@ -218,6 +220,9 @@ export class Message extends BaseClass { @Column({ type: "simple-json", nullable: true }) components?: MessageComponent[]; + @Column({ nullable: true }) + federatedId: string; + toJSON(): Message { return { ...this, @@ -225,6 +230,7 @@ export class Message extends BaseClass { member_id: undefined, webhook_id: undefined, application_id: undefined, + federatedId: undefined, nonce: this.nonce ?? undefined, tts: this.tts ?? false, @@ -240,6 +246,47 @@ export class Message extends BaseClass { components: this.components ?? undefined, }; } + + toAnnounceAP(): APAnnounce { + const { webDomain } = Config.get().federation; + + return { + id: `https://${webDomain}/fed/channel/${this.channel_id}/messages/${this.id}`, + type: "Announce", + actor: `https://${webDomain}/fed/user/${this.author_id}`, + published: this.timestamp, + to: ["https://www.w3.org/ns/activitystreams#Public"], + object: this.toAP(), + }; + } + + toCreateAP(): APCreate { + const { webDomain } = Config.get().federation; + + return { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Create", + id: `https://${webDomain}/fed/channel/${this.channel_id}/messages/${this.id}`, + to: [], + actor: `https://${webDomain}/fed/user/${this.author_id}`, + object: this.toAP(), + }; + } + + // TODO: move to AP module + toAP(): APNote { + const { webDomain } = Config.get().federation; + + return { + id: `https://${webDomain}/fed/messages/${this.id}`, + type: "Note", + published: this.timestamp, + url: `https://${webDomain}/fed/messages/${this.id}`, + attributedTo: `https://${webDomain}/fed/user/${this.author_id}`, + to: ["https://www.w3.org/ns/activitystreams#Public"], + content: this.content, + }; + } } export interface MessageComponent { diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts
index c6582b00..1594093f 100644 --- a/src/util/entities/User.ts +++ b/src/util/entities/User.ts
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ +import { APPerson } from "activitypub-types"; import { Request } from "express"; import { Column, @@ -35,6 +36,10 @@ import { SecurityKey } from "./SecurityKey"; import { Session } from "./Session"; import { UserSettings } from "./UserSettings"; +import crypto from "crypto"; +import { promisify } from "util"; +const generateKeyPair = promisify(crypto.generateKeyPair); + export enum PublicUserEnum { username, discriminator, @@ -85,6 +90,16 @@ export interface UserPrivate extends Pick<User, PrivateUserKeys> { locale: string; } +export interface APPersonButMore extends APPerson { + publicKey: { + id: string; + owner: string; + publicKeyPem: string; + }; + + discoverable: boolean; +} + @Entity("users") export class User extends BaseClass { @Column() @@ -231,6 +246,15 @@ export class User extends BaseClass { @OneToMany(() => SecurityKey, (key: SecurityKey) => key.user) security_keys: SecurityKey[]; + @Column() + publicKey: string; + + @Column({ select: false }) + privateKey: string; + + @Column({ nullable: true }) + federatedId: string; + // TODO: I don't like this method? validate() { if (this.discriminator) { @@ -271,6 +295,37 @@ export class User extends BaseClass { return user as UserPrivate; } + // TODO: move to AP module + toAP(): APPersonButMore { + const { webDomain } = Config.get().federation; + + return { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Person", + id: `https://${webDomain}/fed/user/${this.id}`, + name: this.username, + preferredUsername: this.id, + summary: this.bio, + icon: this.avatar + ? [ + `${Config.get().cdn.endpointPublic}/avatars/${ + this.id + }/${this.avatar}`, + ] + : undefined, + discoverable: true, + + inbox: `https://${webDomain}/fed/user/${this.id}/inbox`, + outbox: `https://${webDomain}/fed/user/${this.id}/outbox`, + followers: `https://${webDomain}/fed/user/${this.id}/followers`, + publicKey: { + id: `https://${webDomain}/fed/user/${this.id}#main-key`, + owner: `https://${webDomain}/fed/user/${this.id}`, + publicKeyPem: this.publicKey, + }, + }; + } + static async getPublicUser(user_id: string, opts?: FindOneOptions<User>) { return await User.findOneOrFail({ where: { id: user_id }, @@ -362,6 +417,18 @@ export class User extends BaseClass { locale: language, }); + const { publicKey, privateKey } = await generateKeyPair("rsa", { + modulusLength: 4096, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + }); + const user = User.create({ username: username, discriminator, @@ -372,7 +439,9 @@ export class User extends BaseClass { valid_tokens_since: new Date(), }, extended_settings: "{}", - settings: settings, + settings, + publicKey, + privateKey, premium_since: Config.get().defaults.user.premium ? new Date() diff --git a/src/util/schemas/MessageAcknowledgeSchema.ts b/src/util/schemas/MessageAcknowledgeSchema.ts
index 28cd9c79..726dc21b 100644 --- a/src/util/schemas/MessageAcknowledgeSchema.ts +++ b/src/util/schemas/MessageAcknowledgeSchema.ts
@@ -19,4 +19,7 @@ export interface MessageAcknowledgeSchema { manual?: boolean; mention_count?: number; + flags?: number; + last_viewed?: number; + token?: unknown; // was null } diff --git a/src/util/schemas/responses/WebfingerResponse.ts b/src/util/schemas/responses/WebfingerResponse.ts new file mode 100644
index 00000000..6b0ab0f9 --- /dev/null +++ b/src/util/schemas/responses/WebfingerResponse.ts
@@ -0,0 +1,12 @@ +interface WebfingerLink { + rel: string; + type?: string; + href: string; + template?: string; +} + +export interface WebfingerResponse { + subject: string; + aliases: string[]; + links: WebfingerLink[]; +} diff --git a/src/util/schemas/responses/index.ts b/src/util/schemas/responses/index.ts
index d8b7fd57..66b9986b 100644 --- a/src/util/schemas/responses/index.ts +++ b/src/util/schemas/responses/index.ts
@@ -28,7 +28,8 @@ export * from "./TypedResponses"; export * from "./UpdatesResponse"; export * from "./UserNoteResponse"; export * from "./UserProfileResponse"; -export * from "./UserRelationshipsResponse"; export * from "./UserRelationsResponse"; +export * from "./UserRelationshipsResponse"; export * from "./WebAuthnCreateResponse"; +export * from "./WebfingerResponse"; export * from "./WebhookCreateResponse"; diff --git a/src/util/util/Event.ts b/src/util/util/Event.ts
index 01f4911a..76a529ed 100644 --- a/src/util/util/Event.ts +++ b/src/util/util/Event.ts
@@ -17,9 +17,9 @@ */ import { Channel } from "amqplib"; -import { RabbitMQ } from "./RabbitMQ"; -import EventEmitter from "events"; +import EventEmitter from "eventemitter2"; import { EVENT, Event } from "../interfaces"; +import { RabbitMQ } from "./RabbitMQ"; export const events = new EventEmitter(); export async function emitEvent(payload: Omit<Event, "created_at">) {