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">) {
|