diff --git a/assets/schemas.json b/assets/schemas.json index 3891cee76..ec0b51e9d 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/package.json b/package.json index 70e4ae849..d2c68a77c 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,8 @@ "@spacebar/api": "dist/api", "@spacebar/cdn": "dist/cdn", "@spacebar/gateway": "dist/gateway", - "@spacebar/util": "dist/util" + "@spacebar/util": "dist/util", + "@spacebar/ap": "dist/activitypub" }, "optionalDependencies": { "erlpack": "^0.1.4", @@ -122,4 +123,4 @@ "nodemailer-sendgrid-transport": "github:Maria-Golomb/nodemailer-sendgrid-transport", "sqlite3": "^5.1.6" } -} +} \ No newline at end of file diff --git a/src/activitypub/Server.ts b/src/activitypub/Server.ts new file mode 100644 index 000000000..492d43b65 --- /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) { + // 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 000000000..7513bd2fd --- /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 000000000..95495ffe0 --- /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 000000000..6b8060879 --- /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 000000000..03a312538 --- /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 & { + where: { id?: FindOperator | FindOperator[] }; + } = { + 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 000000000..838d14b70 --- /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 000000000..3f28fa42c --- /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 000000000..e69de29bb diff --git a/src/activitypub/webfinger/index.ts b/src/activitypub/webfinger/index.ts new file mode 100644 index 000000000..0b82e1039 --- /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) => { + 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 d281120d1..d5e2d6def 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 90b98b7a2..0b3a41522 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 000000000..b04388fd4 --- /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 000000000..a3186a031 --- /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 d8b7fd575..66b9986b9 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/tsconfig.json b/tsconfig.json index 63b5e96cb..1ced0e3c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,7 +37,8 @@ "@spacebar/api*": ["./api"], "@spacebar/gateway*": ["./gateway"], "@spacebar/cdn*": ["./cdn"], - "@spacebar/util*": ["./util"] + "@spacebar/util*": ["./util"], + "@spacebar/ap*": ["./activitypub"] } /* Specify a set of entries that re-map imports to additional lookup locations. */, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */