summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorMadeline <46743919+MaddyUnderStars@users.noreply.github.com>2023-08-14 12:49:20 +1000
committerMadeline <46743919+MaddyUnderStars@users.noreply.github.com>2023-08-14 12:49:20 +1000
commitc560d58f68278aa07fc5847517431cc47100b8e4 (patch)
tree609f7e2725cc347c9958623bc0315dd913c99978 /src
parentremove INVALID_PASSWORD error response. close #1090 (diff)
downloadserver-c560d58f68278aa07fc5847517431cc47100b8e4.tar.xz
random bullshit, go!!
Diffstat (limited to 'src')
-rw-r--r--src/activitypub/Server.ts64
-rw-r--r--src/activitypub/index.ts1
-rw-r--r--src/activitypub/routes/channel/#channel_id/index.ts30
-rw-r--r--src/activitypub/routes/channel/#channel_id/messages/#message_id/index.ts50
-rw-r--r--src/activitypub/routes/channel/#channel_id/outbox.ts76
-rw-r--r--src/activitypub/routes/user.ts36
-rw-r--r--src/activitypub/start.ts7
-rw-r--r--src/activitypub/util/actor.ts0
-rw-r--r--src/activitypub/webfinger/index.ts63
-rw-r--r--src/bundle/Server.ts18
-rw-r--r--src/util/config/Config.ts2
-rw-r--r--src/util/config/types/FederationConfiguration.ts5
-rw-r--r--src/util/schemas/responses/WebfingerResponse.ts12
-rw-r--r--src/util/schemas/responses/index.ts3
14 files changed, 361 insertions, 6 deletions
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";