diff --git a/src/activitypub/Server.ts b/src/activitypub/Server.ts
new file mode 100644
index 00000000..492d43b6
--- /dev/null
+++ b/src/activitypub/Server.ts
@@ -0,0 +1,64 @@
+import { BodyParser, CORS, ErrorHandler } from "@spacebar/api";
+import {
+ Config,
+ JSONReplacer,
+ initDatabase,
+ registerRoutes,
+} from "@spacebar/util";
+import { Request, Response, Router } from "express";
+import { Server, ServerOptions } from "lambert-server";
+import path from "path";
+import webfinger from "./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();
+
+ this.app.set("json replacer", JSONReplacer);
+
+ this.app.use(CORS);
+ this.app.use(BodyParser({ inflate: true, limit: "10mb" }));
+
+ 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("/fed", api);
+ this.app.get("/fed", (req, res) => {
+ res.json({ ping: "pong" });
+ });
+
+ this.app.use("/.well-known/webfinger", webfinger);
+
+ 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..7513bd2f
--- /dev/null
+++ b/src/activitypub/index.ts
@@ -0,0 +1 @@
+export * from "./Server";
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..95495ffe
--- /dev/null
+++ b/src/activitypub/routes/channel/#channel_id/index.ts
@@ -0,0 +1,30 @@
+import { route } from "@spacebar/api";
+import { Channel, Config } 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.id;
+
+ const channel = await Channel.findOneOrFail({ where: { id } });
+
+ const { webDomain } = Config.get().federation;
+
+ return res.json({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ type: "Group",
+ id: `https://${webDomain}/fed/channel/${channel.id}`,
+ name: channel.name,
+ preferredUsername: channel.name,
+ summary: channel.topic,
+ icon: undefined,
+
+ inbox: `https://${webDomain}/fed/channel/${channel.id}/inbox`,
+ outbox: `https://${webDomain}/fed/channel/${channel.id}/outbox`,
+ followers: `https://${webDomain}/fed/channel/${channel.id}/followers`,
+ following: `https://${webDomain}/fed/channel/${channel.id}/following`,
+ linked: `https://${webDomain}/fed/channel/${channel.id}/likeds`,
+ });
+});
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..6b806087
--- /dev/null
+++ b/src/activitypub/routes/channel/#channel_id/messages/#message_id/index.ts
@@ -0,0 +1,50 @@
+import { route } from "@spacebar/api";
+import { Config, Message } from "@spacebar/util";
+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;
+
+ return res.json({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ id: "Announce",
+ actor: `https://${webDomain}/fed/user/${message.author!.id}`,
+ published: message.timestamp,
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
+ cc: [
+ message.author?.id
+ ? `https://${webDomain}/fed/users/${message.author.id}`
+ : undefined,
+ `https://${webDomain}/fed/channel/${channel_id}/followers`,
+ ],
+ object: {
+ id: `https://${webDomain}/fed/channel/${channel_id}/mesages/${message.id}`,
+ type: "Note",
+ summary: null,
+ inReplyTo: undefined, // TODO
+ published: message.timestamp,
+ url: `https://app.spacebar.chat/channels${
+ message.guild?.id ? `/${message.guild.id}` : ""
+ }/${channel_id}/${message.id}`,
+ attributedTo: `https://${webDomain}/fed/user/${message.author!.id}`,
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
+ cc: [
+ message.author?.id
+ ? `https://${webDomain}/fed/users/${message.author.id}`
+ : undefined,
+ `https://${webDomain}/fed/channel/${channel_id}/followers`,
+ ],
+ sensitive: false,
+ content: message.content,
+ },
+ });
+});
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..03a31253
--- /dev/null
+++ b/src/activitypub/routes/channel/#channel_id/outbox.ts
@@ -0,0 +1,76 @@
+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;
+
+ if (!page)
+ return res.json({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ id: `https://${webDomain}/fed/users/${channel_id}/outbox`,
+ type: "OrderedCollection",
+ first: `https://${webDomain}/fed/users/${channel_id}/outbox?page=true`,
+ last: `https://${webDomain}/fed/users/${channel_id}/outbox?page=true&min_id=0`,
+ });
+
+ const after = min_id ? `${min_id}` : undefined;
+ const before = max_id ? `${max_id}` : undefined;
+
+ 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 res.status(422);
+ query.where.id = MoreThan(after);
+ } else if (before) {
+ if (BigInt(before) > BigInt(Snowflake.generate()))
+ return res.status(422);
+ query.where.id = LessThan(before);
+ }
+
+ const messages = await Message.find(query);
+
+ return res.json({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ id: `https://${webDomain}/fed/channel/${channel_id}/outbox?page=true`,
+ type: "OrderedCollection",
+ next: `https://${webDomain}/fed/channel/${channel_id}/outbox?page=true&max_id=${
+ messages[0]?.id || "0"
+ }`,
+ prev: `https://${webDomain}/fed/channel/${channel_id}/outbox?page=true&max_id=${
+ messages[messages.length - 1]?.id || "0"
+ }`,
+ partOf: `https://${webDomain}/fed/channel/${channel_id}/outbox`,
+ orderedItems: messages.map((message) => ({
+ id: `https://${webDomain}/fed/channel/${channel_id}/message/${message.id}`,
+ type: "Announce", // hmm
+ actor: `https://${webDomain}/fed/channel/${channel_id}`,
+ published: message.timestamp,
+ to: ["https://www.w3.org/ns/activitystreams#Public"],
+ cc: [
+ message.author?.id
+ ? `https://${webDomain}/fed/users/${message.author.id}`
+ : undefined,
+ `https://${webDomain}/fed/channel/${channel_id}/followers`,
+ ],
+ object: `https://${webDomain}/fed/channel/${channel_id}/messages/${message.id}`,
+ })),
+ });
+});
diff --git a/src/activitypub/routes/user.ts b/src/activitypub/routes/user.ts
new file mode 100644
index 00000000..838d14b7
--- /dev/null
+++ b/src/activitypub/routes/user.ts
@@ -0,0 +1,36 @@
+import { route } from "@spacebar/api";
+import { Config, User } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+export default router;
+
+router.get("/:id", route({}), async (req: Request, res: Response) => {
+ const id = req.params.name;
+
+ const user = await User.findOneOrFail({ where: { id } });
+
+ const { webDomain } = Config.get().federation;
+
+ return res.json({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ type: "Person",
+ id: `https://${webDomain}/fed/user/${user.id}`,
+ name: user.username,
+ preferredUsername: user.username,
+ summary: user.bio,
+ icon: user.avatar
+ ? [
+ `${Config.get().cdn.endpointPublic}/avatars/${user.id}/${
+ user.avatar
+ }`,
+ ]
+ : undefined,
+
+ inbox: `https://${webDomain}/fed/user/${user.id}/inbox`,
+ outbox: `https://${webDomain}/fed/user/${user.id}/outbox`,
+ followers: `https://${webDomain}/fed/user/${user.id}/followers`,
+ following: `https://${webDomain}/fed/user/${user.id}/following`,
+ linked: `https://${webDomain}/fed/user/${user.id}/likeds`,
+ });
+});
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/actor.ts b/src/activitypub/util/actor.ts
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/activitypub/util/actor.ts
diff --git a/src/activitypub/webfinger/index.ts b/src/activitypub/webfinger/index.ts
new file mode 100644
index 00000000..0b82e103
--- /dev/null
+++ b/src/activitypub/webfinger/index.ts
@@ -0,0 +1,63 @@
+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:", "");
+
+ 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";
+
+ 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}`,
+ },
+ ],
+ });
+ },
+);
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/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/schemas/responses/WebfingerResponse.ts b/src/util/schemas/responses/WebfingerResponse.ts
new file mode 100644
index 00000000..a3186a03
--- /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";
|