From c82b71695d16b0ef19d5e976ea7ca47c62ef3345 Mon Sep 17 00:00:00 2001 From: Madeline <46743919+MaddyUnderStars@users.noreply.github.com> Date: Thu, 28 Sep 2023 16:10:57 +0000 Subject: [PATCH] send Follow request to guild when remote invite code used --- .vscode/launch.json | 23 ++++- package.json | 3 +- src/activitypub/README.md | 19 ++++ src/activitypub/Server.ts | 35 ++++--- src/activitypub/federation/HttpSig.ts | 18 ++-- src/activitypub/federation/index.ts | 5 +- src/activitypub/federation/queue.ts | 66 +++++++++---- src/activitypub/federation/transforms.ts | 58 ++++++++++- src/activitypub/federation/utils.ts | 50 ++++++++-- .../routes/channels/#channel_id/inbox.ts | 8 +- .../routes/guilds/#guild_id/inbox.ts | 13 +++ src/activitypub/start.ts | 13 +++ .../channels/#channel_id/messages/index.ts | 1 + .../routes/guilds/#guild_id/welcome-screen.ts | 8 ++ src/api/routes/invites/index.ts | 36 +++++-- src/bundle/Server.ts | 11 ++- src/bundle/index.ts | 5 +- src/util/entities/FederationActivity.ts | 18 ++++ src/util/entities/Guild.ts | 95 ++++++++++--------- src/util/entities/index.ts | 1 + src/util/util/morgan.ts | 18 ++-- 21 files changed, 378 insertions(+), 126 deletions(-) create mode 100644 src/activitypub/routes/guilds/#guild_id/inbox.ts create mode 100644 src/activitypub/start.ts create mode 100644 src/util/entities/FederationActivity.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 611fefa0..2ad20e0f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,28 @@ "skipFiles": ["/**"], "program": "${workspaceFolder}/src/bundle/start.ts", "outFiles": ["${workspaceFolder}/dist/**/*.js"], - "preLaunchTask": "tsc: build - tsconfig.json" + "preLaunchTask": "tsc: build - tsconfig.json", + "outputCapture": "std" + }, + { + "type": "node", + "request": "launch", + "name": "API", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/src/api/start.ts", + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "preLaunchTask": "tsc: build - tsconfig.json", + "outputCapture": "std" + }, + { + "type": "node", + "request": "launch", + "name": "Activitypub", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/src/activitypub/start.ts", + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "preLaunchTask": "tsc: build - tsconfig.json", + "outputCapture": "std" } ] } diff --git a/package.json b/package.json index e5f6ce2a..b2c6885c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "node dist/bundle/start.js", "start:api": "node dist/api/start.js", "start:cdn": "node dist/cdn/start.js", + "start:ap": "node dist/activitypub/start.js", "start:gateway": "node dist/gateway/start.js", "build": "tsc -p .", "test": "node scripts/test.js", @@ -126,4 +127,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/README.md b/src/activitypub/README.md index 2f561d96..836741ba 100644 --- a/src/activitypub/README.md +++ b/src/activitypub/README.md @@ -1,9 +1,26 @@ # Spacebar Activitypub +## Activitypub Specification + - [Activitystreams vocab](https://www.w3.org/TR/activitystreams-vocabulary) - [Activitystreams](https://www.w3.org/TR/activitystreams-core) - [Activitypub spec](https://www.w3.org/TR/activitypub/) +## Additional resources + +- [Activitypub as it has been understood](https://flak.tedunangst.com/post/ActivityPub-as-it-has-been-understood) +- [Guide for new ActivityPub implementers](https://socialhub.activitypub.rocks/t/guide-for-new-activitypub-implementers/479) +- Understanding activitypub + [part 1](https://seb.jambor.dev/posts/understanding-activitypub/), + [part 2](https://seb.jambor.dev/posts/understanding-activitypub-part-2-lemmy/), + [part 3](https://seb.jambor.dev/posts/understanding-activitypub-part-3-the-state-of-mastodon/) +- [Nodejs Express Activitypub sample implementation](https://github.com/dariusk/express-activitypub) +- [Reading Activitypub](https://tinysubversions.com/notes/reading-activitypub/#the-ultimate-tl-dr) + +# Spacebar Activitypub Docs + +Incomplete documentation. + ## Supported Types | Spacebar object | Activitypub | @@ -93,6 +110,8 @@ Also contains a collection of [roles](#role-federation). ### Properties Used +- attributed to is owner + ## User Federation A person. Sends messages to Channels. May also create, modify, or moderate guilds, channels, or roles. diff --git a/src/activitypub/Server.ts b/src/activitypub/Server.ts index 1f55f76c..9b02d67b 100644 --- a/src/activitypub/Server.ts +++ b/src/activitypub/Server.ts @@ -1,4 +1,4 @@ -import { BodyParser, CORS, ErrorHandler } from "@spacebar/api"; +import { CORS, ErrorHandler } from "@spacebar/api"; import { Config, JSONReplacer, @@ -13,7 +13,7 @@ import { Server, ServerOptions } from "lambert-server"; import path from "path"; import wellknown from "./well-known"; -export type SpacebarServerOptions = ServerOptions; +type SpacebarServerOptions = ServerOptions; export class FederationServer extends Server { public declare options: SpacebarServerOptions; @@ -29,18 +29,23 @@ export class FederationServer extends Server { await Config.init(); await Sentry.init(this.app); - setupMorganLogging(this.app); - this.app.set("json replacer", JSONReplacer); + if (!Config.get().federation.enabled) { + return; + } + console.log("Federation is enabled!"); + + this.app.set("json replacer", JSONReplacer); this.app.use(CORS); + this.app.use(bodyParser.json({ inflate: true })); this.app.use( - BodyParser({ - inflate: true, - limit: "10mb", + bodyParser.json({ type: "application/activity+json", }), ); - this.app.use(bodyParser.urlencoded({ extended: true })); + this.app.use(bodyParser.urlencoded({ inflate: true, extended: true })); + + setupMorganLogging(this.app); const app = this.app; const api = Router(); @@ -64,13 +69,6 @@ export class FederationServer extends Server { 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("/federation", api); @@ -80,6 +78,13 @@ export class FederationServer extends Server { Sentry.errorHandler(this.app); + api.use("*", (req: Request, res: Response) => { + res.status(404).json({ + message: "404 endpoint not found", + code: 0, + }); + }); + return super.start(); } } diff --git a/src/activitypub/federation/HttpSig.ts b/src/activitypub/federation/HttpSig.ts index aeedd2c2..ce34b98e 100644 --- a/src/activitypub/federation/HttpSig.ts +++ b/src/activitypub/federation/HttpSig.ts @@ -2,6 +2,7 @@ import { Config, FederationKey, OrmUtils } from "@spacebar/util"; import { APActivity } from "activitypub-types"; import crypto from "crypto"; import { IncomingHttpHeaders } from "http"; +import { RequestInit } from "node-fetch"; import { APError, fetchFederatedUser, fetchOpts } from "./utils"; export class HttpSig { @@ -27,8 +28,9 @@ export class HttpSig { activity: APActivity, requestHeaders: IncomingHttpHeaders, ) { - const sigheader = requestHeaders["signature"] as string; - const sigopts: { [key: string]: string } = Object.assign( + const sigheader = requestHeaders["signature"]?.toString(); + if (!sigheader) throw new APError("Missing signature"); + const sigopts: { [key: string]: string | undefined } = Object.assign( {}, ...sigheader.split(",").flatMap((keyval) => { const split = keyval.split("="); @@ -43,8 +45,10 @@ export class HttpSig { if (!signature || !headers || !keyId) throw new APError("Invalid signature"); + const ALLOWED_ALGO = "rsa-sha256"; + // If it's provided, check it. otherwise just assume it's sha256 - if (algorithm && algorithm != "rsa-sha256") + if (algorithm && algorithm != ALLOWED_ALGO) throw new APError(`Unsupported encryption algorithm ${algorithm}`); const url = new URL(keyId); @@ -59,7 +63,9 @@ export class HttpSig { headers.split(/\s+/), ); - const verifier = crypto.createVerify(algorithm.toUpperCase()); + const verifier = crypto.createVerify( + algorithm?.toUpperCase() || ALLOWED_ALGO, + ); verifier.write(expected); verifier.end(); @@ -113,13 +119,13 @@ export class HttpSig { return OrmUtils.mergeDeep(fetchOpts, { method: "POST", - body: message, + body: JSON.stringify(message), headers: { Host: url.hostname, Date: now.toUTCString(), Digest: `SHA-256=${digest}`, Signature: header, }, - }); + } as RequestInit); } } diff --git a/src/activitypub/federation/index.ts b/src/activitypub/federation/index.ts index 837d5d8f..a3504da6 100644 --- a/src/activitypub/federation/index.ts +++ b/src/activitypub/federation/index.ts @@ -30,7 +30,10 @@ export class Federation { throw new APError("Invalid signature"); } - if (!APActivityIsCreate(activity)) throw new APError("not implemented"); + if (!APActivityIsCreate(activity)) + throw new APError( + `activity of type ${activity.type} not implemented`, + ); const object = Array.isArray(activity.object) ? activity.object[0] diff --git a/src/activitypub/federation/queue.ts b/src/activitypub/federation/queue.ts index fddc24b1..c62017e6 100644 --- a/src/activitypub/federation/queue.ts +++ b/src/activitypub/federation/queue.ts @@ -12,14 +12,11 @@ class FederationQueue { private queue: Map> = new Map(); public async distribute(activity: APActivity) { - let { to, actor } = activity; - - if (!to) - throw new APError("Activity with no `to` field is undeliverable."); - if (!Array.isArray(to)) to = [to]; + let { actor } = activity; + const { to, object } = activity; if (!actor) - throw new APError("Activity with no `to` field is undeliverable."); + throw new APError("Activity with no actor cannot be signed."); if (Array.isArray(actor)) actor = actor[0]; // TODO: check if `to` is on our instance? @@ -38,21 +35,56 @@ class FederationQueue { return; } - for (const receiver of to) { - if (typeof receiver != "string") { - console.error(receiver); + // this is ugly + for (let recv of [ + ...(Array.isArray(to) ? to : [to]), + ...(Array.isArray(object) ? object : [object]), + ]) { + if (!recv) continue; + + if (typeof recv != "string") { + console.warn( + `TODO: activity with non-string destination was not sent`, + recv, + ); continue; } - const signedActivity = await HttpSig.sign( - receiver.toString(), - sender, - activity, + if (recv == "https://www.w3.org/ns/activitystreams#Public") { + console.debug(`TODO: Skipping sending activity to #Public`); + continue; + } + + if (recv.includes("/followers")) { + console.warn("sending to /followers is not implemented"); + continue; + } + + // TODO: this is bad + if (!recv.includes("/inbox")) recv = `${recv}/inbox`; + + await this.signAndSend(activity, sender, recv); + } + } + + private async signAndSend( + activity: APActivity, + sender: FederationKey, + receiver: string, + ) { + const signedActivity = await HttpSig.sign( + receiver.toString(), + sender, + activity, + ); + + const ret = await fetch(receiver, signedActivity); + if (!ret.ok) { + console.error( + `Sending activity ${activity.id} to ` + + `${receiver} failed with code ${ret.status} `, + JSON.stringify(await ret.json()), ); - - const ret = await fetch(receiver, signedActivity); - - console.log(ret); } } } diff --git a/src/activitypub/federation/transforms.ts b/src/activitypub/federation/transforms.ts index e1e78754..fd347116 100644 --- a/src/activitypub/federation/transforms.ts +++ b/src/activitypub/federation/transforms.ts @@ -25,6 +25,7 @@ import { ACTIVITYSTREAMS_CONTEXT, APError, APObjectIsPerson, + fetchFederatedUser, resolveAPObject, } from "./utils"; @@ -257,7 +258,7 @@ export const transformPersonToUser = async (person: APPerson) => { const keys = await FederationKey.create({ actorId: Snowflake.generate(), federatedId: url.toString(), - username: person.preferredUsername, + username: person.name, domain: url.hostname, publicKey: person.publicKey?.publicKeyPem, type: ActorType.USER, @@ -289,18 +290,63 @@ export const transformPersonToUser = async (person: APPerson) => { }).save(); }; -export const transformOrganisationToInvite = (guild: APOrganization) => { +export const transformOrganisationToInvite = async ( + code: string, + org: APOrganization, +) => { + const guild = await transformOrganisationToGuild(org); return Invite.create({ - code: guild.id, + code, temporary: false, uses: -1, max_uses: 0, max_age: 0, created_at: new Date(0), flags: 0, + guild, + channel: Channel.create({}), + inviter: guild.owner, }); }; +export const transformOrganisationToGuild = async (org: APOrganization) => { + if (!org.id) throw new APError("Federated guild must have ID"); + if (!org.publicKey || !org.publicKey.publicKeyPem) + throw new APError("Federated guild must have public key."); + + const cache = await FederationKey.findOne({ + where: { federatedId: org.id }, + }); + if (cache) { + return await Guild.findOneOrFail({ where: { id: cache.actorId } }); + } + + const keys = FederationKey.create({ + actorId: Snowflake.generate(), + federatedId: org.id, + username: org.name, + domain: new URL(org.id).hostname, + publicKey: org.publicKey.publicKeyPem, + type: ActorType.GUILD, + inbox: org.inbox.toString(), + outbox: org.outbox.toString(), + }); + + if (typeof org.attributedTo != "string") + throw new APError("attributedTo must be string"); + + const owner = await fetchFederatedUser(org.attributedTo); + + const guild = Guild.create({ + id: keys.actorId, + name: org.name, + owner_id: owner.user.id, + }); + + await Promise.all([guild.save(), keys.save()]); + return guild; +}; + export const transformGuildToOrganisation = async ( guild: Guild, ): Promise => { @@ -317,7 +363,11 @@ export const transformGuildToOrganisation = async ( name: guild.name, preferredUsername: guild.id, - icon: undefined, + icon: guild.icon + ? `${Config.get().cdn.endpointPublic}/icons/${guild.icon}` + : undefined, + + attributedTo: `https://${host}/federation/users/${guild.owner_id}`, inbox: `https://${host}/federation/guilds/${guild.id}/inbox`, outbox: `https://${host}/federation/guilds/${guild.id}/outbox`, diff --git a/src/activitypub/federation/utils.ts b/src/activitypub/federation/utils.ts index bcaaf45f..4bb9279c 100644 --- a/src/activitypub/federation/utils.ts +++ b/src/activitypub/federation/utils.ts @@ -2,6 +2,7 @@ import { DEFAULT_FETCH_OPTIONS } from "@spacebar/api"; import { ActorType, Config, + FederationActivity, FederationKey, OrmUtils, Snowflake, @@ -13,6 +14,7 @@ import { APActivity, APAnnounce, APCreate, + APFollow, APNote, APPerson, AnyAPObject, @@ -21,14 +23,18 @@ import { HTTPError } from "lambert-server"; import fetch from "node-fetch"; import { ProxyAgent } from "proxy-agent"; import TurndownService from "turndown"; +import { Federation } from "."; export const ACTIVITYSTREAMS_CONTEXT = "https://www.w3.org/ns/activitystreams"; -export const fetchOpts = OrmUtils.mergeDeep(DEFAULT_FETCH_OPTIONS, { - headers: { - Accept: "application/activity+json", - }, -}); +export const fetchOpts = Object.freeze( + OrmUtils.mergeDeep(DEFAULT_FETCH_OPTIONS, { + headers: { + Accept: "application/activity+json", + "Content-Type": "application/activity+json", + }, + }), +); export class APError extends HTTPError {} @@ -112,6 +118,15 @@ export const resolveWebfinger = async ( return await resolveAPObject(link.href); }; +export const tryResolveWebfinger = async (lookup: string) => { + try { + return await resolveWebfinger(lookup); + } catch (e) { + console.error(`Error resolving webfinger ${lookup}`, e); + return null; + } +}; + /** Fetch from local db, if not found fetch from remote instance and save */ export const fetchFederatedUser = async (actorId: string) => { // if we were given webfinger, resolve that first @@ -147,7 +162,7 @@ export const fetchFederatedUser = async (actorId: string) => { const keys = FederationKey.create({ actorId: Snowflake.generate(), federatedId: actorId, - username: remoteActor.preferredUsername, + username: remoteActor.name, // this is technically not correct // but it's slightly more difficult to go from actor url -> handle // so thats a problem for future me @@ -160,7 +175,7 @@ export const fetchFederatedUser = async (actorId: string) => { const user = User.create({ id: keys.actorId, - username: remoteActor.preferredUsername, + username: remoteActor.name, discriminator: "0", bio: new TurndownService().turndown(remoteActor.summary), // html -> markdown email: `${remoteActor.preferredUsername}@${keys.domain}`, @@ -188,6 +203,27 @@ export const fetchFederatedUser = async (actorId: string) => { }; }; +export const tryFederatedGuildJoin = async (code: string, user_id: string) => { + const guild = await tryResolveWebfinger(code); + if (!guild || !APObjectIsOrganisation(guild)) + throw new APError( + `Invite code did not produce Guild on remote server ${code}`, + ); + + const { host } = Config.get().federation; + + const follow = await FederationActivity.create({ + data: { + "@context": ACTIVITYSTREAMS_CONTEXT, + type: "Follow", + actor: `https://${host}/federation/users/${user_id}`, + object: guild.id, + } as APFollow, + }).save(); + + await Federation.distribute(follow.toJSON()); +}; + // fetch from remote server? export const APObjectIsPerson = (object: AnyAPObject): object is APPerson => { return "type" in object && object.type == "Person"; diff --git a/src/activitypub/routes/channels/#channel_id/inbox.ts b/src/activitypub/routes/channels/#channel_id/inbox.ts index 896522b7..26b416f9 100644 --- a/src/activitypub/routes/channels/#channel_id/inbox.ts +++ b/src/activitypub/routes/channels/#channel_id/inbox.ts @@ -1,14 +1,10 @@ -import { transformNoteToMessage } from "@spacebar/ap"; +import { Federation } from "@spacebar/ap"; import { route } from "@spacebar/api"; -import { Message, emitEvent } from "@spacebar/util"; -import { APCreate, APNote } from "activitypub-types"; import { Request, Response, Router } from "express"; -import { HTTPError } from "lambert-server"; const router = Router(); router.post("/", route({}), async (req: Request, res: Response) => { - // TODO: check if the activity exists on the remote server - // TODO: refactor + res.json(await Federation.genericInboxHandler(req)); }); export default router; diff --git a/src/activitypub/routes/guilds/#guild_id/inbox.ts b/src/activitypub/routes/guilds/#guild_id/inbox.ts new file mode 100644 index 00000000..03c4991a --- /dev/null +++ b/src/activitypub/routes/guilds/#guild_id/inbox.ts @@ -0,0 +1,13 @@ +import { Federation } from "@spacebar/ap"; +import { route } from "@spacebar/api"; +import { Request, Response, Router } from "express"; +const router = Router(); + +router.post("/", route({}), async (req: Request, res: Response) => { + // TODO: support lemmy ChatMessage type? + // TODO: check if the activity exists on the remote server + + res.json(await Federation.genericInboxHandler(req)); +}); + +export default router; diff --git a/src/activitypub/start.ts b/src/activitypub/start.ts new file mode 100644 index 00000000..c88fed42 --- /dev/null +++ b/src/activitypub/start.ts @@ -0,0 +1,13 @@ +require("module-alias/register"); +import "dotenv/config"; + +import { FederationServer } from "./Server"; +const server = new FederationServer({ port: Number(process.env.PORT) || 3003 }); +server + .start() + .then(() => { + console.log("[Server] started on :" + server.options.port); + }) + .catch((e) => console.error("[Server] Error starting: ", e)); + +module.exports = server; diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts index e303053c..c76df782 100644 --- a/src/api/routes/channels/#channel_id/messages/index.ts +++ b/src/api/routes/channels/#channel_id/messages/index.ts @@ -406,6 +406,7 @@ router.post( ); setImmediate(async () => { + if (!Config.get().federation.enabled) return; const ap = await transformMessageToAnnounceNoce(message); await Federation.distribute(ap); diff --git a/src/api/routes/guilds/#guild_id/welcome-screen.ts b/src/api/routes/guilds/#guild_id/welcome-screen.ts index 81000b4b..f391d7bd 100644 --- a/src/api/routes/guilds/#guild_id/welcome-screen.ts +++ b/src/api/routes/guilds/#guild_id/welcome-screen.ts @@ -70,6 +70,14 @@ router.patch( const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + // TODO: move this + if (!guild.welcome_screen) + guild.welcome_screen = { + enabled: false, + description: "", + welcome_channels: [], + }; + if (body.enabled != undefined) guild.welcome_screen.enabled = body.enabled; diff --git a/src/api/routes/invites/index.ts b/src/api/routes/invites/index.ts index e91215c4..fe983e11 100644 --- a/src/api/routes/invites/index.ts +++ b/src/api/routes/invites/index.ts @@ -19,9 +19,10 @@ import { APError, APObjectIsOrganisation, - resolveWebfinger, splitQualifiedMention, transformOrganisationToInvite, + tryFederatedGuildJoin, + tryResolveWebfinger, } from "@spacebar/ap"; import { route } from "@spacebar/api"; import { @@ -68,14 +69,18 @@ router.get( if (domain != accountDomain && domain != host) { // The domain isn't ours - const remoteGuild = await resolveWebfinger(inputValue); + const remoteGuild = await tryResolveWebfinger(inputValue); + if (remoteGuild) { + if (APObjectIsOrganisation(remoteGuild)) + return res.json( + await transformOrganisationToInvite( + inputValue, + remoteGuild, + ), + ); - if (APObjectIsOrganisation(remoteGuild)) - return res.json( - transformOrganisationToInvite(remoteGuild), - ); - - throw new APError("Remote resource is not a guild"); + throw new APError("Remote resource is not a guild"); + } } } } @@ -110,8 +115,21 @@ router.post( }), async (req: Request, res: Response) => { if (req.user_bot) throw DiscordApiErrors.BOT_PROHIBITED_ENDPOINT; - const { code } = req.params; + + // Federation + const mention = splitQualifiedMention(code); + if (mention.user.length && Config.get().federation.enabled) { + const { domain } = mention; + const { accountDomain, host } = Config.get().federation; + if (domain != accountDomain && domain != host) { + // this domain isn't ours, try a federated join + // send a follow request to the guild + + return res.json(await tryFederatedGuildJoin(code, req.user_id)); + } + } + const { guild_id } = await Invite.findOneOrFail({ where: { code: code }, }); diff --git a/src/bundle/Server.ts b/src/bundle/Server.ts index 72d38603..d76875e5 100644 --- a/src/bundle/Server.ts +++ b/src/bundle/Server.ts @@ -23,7 +23,12 @@ import { FederationServer } from "@spacebar/ap"; import * as Api from "@spacebar/api"; import { CDNServer } from "@spacebar/cdn"; import * as Gateway from "@spacebar/gateway"; -import { Config, Sentry, initDatabase } from "@spacebar/util"; +import { + Config, + Sentry, + initDatabase, + setupMorganLogging, +} from "@spacebar/util"; import express from "express"; import http from "http"; import { bold, green } from "picocolors"; @@ -35,9 +40,9 @@ const production = process.env.NODE_ENV == "development" ? false : true; server.on("request", app); const api = new Api.SpacebarServer({ server, port, production, app }); +const federation = new FederationServer({ server, port, production, app }); const cdn = new CDNServer({ server, port, production, app }); const gateway = new Gateway.Server({ server, port, production }); -const federation = new FederationServer({ server, port, production, app }); process.on("SIGTERM", async () => { console.log("Shutting down due to SIGTERM"); @@ -53,6 +58,8 @@ async function main() { await Config.init(); await Sentry.init(app); + setupMorganLogging(app); + await new Promise((resolve) => server.listen({ port }, () => resolve(undefined)), ); 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 . */ +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/entities/FederationActivity.ts b/src/util/entities/FederationActivity.ts new file mode 100644 index 00000000..b9a1ac33 --- /dev/null +++ b/src/util/entities/FederationActivity.ts @@ -0,0 +1,18 @@ +import { APActivity } from "activitypub-types"; +import { Column, Entity } from "typeorm"; +import { Config } from ".."; +import { BaseClass } from "./BaseClass"; + +@Entity("federation_activities") +export class FederationActivity extends BaseClass { + @Column({ type: "simple-json" }) + data: APActivity; + + toJSON(): APActivity { + const { host } = Config.get().federation; + return { + id: `https://${host}/federation/activities/${this.id}`, + ...this.data, + }; + } +} diff --git a/src/util/entities/Guild.ts b/src/util/entities/Guild.ts index c50e81e2..a7243eff 100644 --- a/src/util/entities/Guild.ts +++ b/src/util/entities/Guild.ts @@ -17,6 +17,7 @@ */ import { + BeforeInsert, Column, Entity, JoinColumn, @@ -83,8 +84,8 @@ export class Guild extends BaseClass { @ManyToOne(() => Channel) afk_channel?: Channel; - @Column({ nullable: true }) - afk_timeout?: number; + @Column() + afk_timeout: number; // * commented out -> use owner instead // application id of the guild creator if it is bot-created @@ -135,11 +136,11 @@ export class Guild extends BaseClass { @Column({ nullable: true }) max_video_channel_users?: number; - @Column({ nullable: true }) - member_count?: number; + @Column({ default: 0 }) + member_count: number; - @Column({ nullable: true }) - presence_count?: number; // users online + @Column({ nullable: true, type: Number, default: 0 }) + presence_count: number | null; // users online @OneToMany(() => Member, (member: Member) => member.guild, { cascade: true, @@ -211,8 +212,8 @@ export class Guild extends BaseClass { }) webhooks: Webhook[]; - @Column({ nullable: true }) - mfa_level?: number; + @Column({ default: 0 }) + mfa_level: number; @Column() name: string; @@ -225,14 +226,14 @@ export class Guild extends BaseClass { @ManyToOne(() => User) owner?: User; // optional to allow for ownerless guilds - @Column({ nullable: true }) - preferred_locale?: string; + @Column({ nullable: true, type: String, default: "en-US" }) + preferred_locale: string | null; - @Column({ nullable: true }) + @Column({ nullable: true, type: Number, default: 0 }) premium_subscription_count?: number; - @Column() - premium_tier?: number; // crowd premium level + @Column({ default: 0 }) + premium_tier: number; // crowd premium level @Column({ nullable: true }) @RelationId((guild: Guild) => guild.public_updates_channel) @@ -264,17 +265,17 @@ export class Guild extends BaseClass { @ManyToOne(() => Channel) system_channel?: Channel; - @Column({ nullable: true }) + @Column({ default: 4 }) // defaults effect: suppress the setup tips to save performance system_channel_flags?: number; @Column() unavailable: boolean = false; - @Column({ nullable: true }) - verification_level?: number; + @Column({ default: 0 }) + verification_level: number; - @Column({ type: "simple-json" }) - welcome_screen: GuildWelcomeScreen; + @Column({ type: "simple-json", nullable: true }) // TODO: move this to own table + welcome_screen: GuildWelcomeScreen | null; @Column({ nullable: true }) @RelationId((guild: Guild) => guild.widget_channel) @@ -287,8 +288,8 @@ export class Guild extends BaseClass { @Column() widget_enabled: boolean = true; - @Column({ nullable: true }) - nsfw_level?: number; + @Column({ default: 0 }) + nsfw_level: number; @Column() nsfw: boolean = false; @@ -317,32 +318,6 @@ export class Guild extends BaseClass { name: body.name || "Spacebar", icon: await handleFile(`/icons/${guild_id}`, body.icon as string), owner_id: body.owner_id, // TODO: need to figure out a way for ownerless guilds and multiply-owned guilds - presence_count: 0, - member_count: 0, // will automatically be increased by addMember() - mfa_level: 0, - preferred_locale: "en-US", - premium_subscription_count: 0, - premium_tier: 0, - system_channel_flags: 4, // defaults effect: suppress the setup tips to save performance - nsfw_level: 0, - verification_level: 0, - welcome_screen: { - enabled: false, - description: "", - welcome_channels: [], - }, - - afk_timeout: Config.get().defaults.guild.afkTimeout, - default_message_notifications: - Config.get().defaults.guild.defaultMessageNotifications, - explicit_content_filter: - Config.get().defaults.guild.explicitContentFilter, - features: Config.get().guild.defaultFeatures, - max_members: Config.get().limits.guild.maxMembers, - max_presences: Config.get().defaults.guild.maxPresences, - max_video_channel_users: - Config.get().defaults.guild.maxVideoChannelUsers, - region: Config.get().regions.default, }).save(); // we have to create the role _after_ the guild because else we would get a "SQLITE_CONSTRAINT: FOREIGN KEY constraint failed" error @@ -414,4 +389,32 @@ export class Guild extends BaseClass { unavailable: this.unavailable == false ? undefined : true, }; } + + @BeforeInsert() + __set_defaults = () => { + if (!this.afk_timeout) + this.afk_timeout = Config.get().defaults.guild.afkTimeout; + + if (!this.default_message_notifications) + this.default_message_notifications = + Config.get().defaults.guild.defaultMessageNotifications; + + if (!this.explicit_content_filter) + this.explicit_content_filter = + Config.get().defaults.guild.explicitContentFilter; + + if (!this.features) this.features = Config.get().guild.defaultFeatures; + + if (!this.max_members) + this.max_members = Config.get().limits.guild.maxMembers; + + if (!this.max_presences) + this.afk_timeout = Config.get().defaults.guild.maxPresences; + + if (!this.max_video_channel_users) + this.max_video_channel_users = + Config.get().defaults.guild.maxVideoChannelUsers; + + if (!this.region) this.region = Config.get().regions.default; + }; } diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts index 708f2801..a5216d5a 100644 --- a/src/util/entities/index.ts +++ b/src/util/entities/index.ts @@ -31,6 +31,7 @@ export * from "./ConnectionConfigEntity"; export * from "./EmbedCache"; export * from "./Emoji"; export * from "./Encryption"; +export * from "./FederationActivity"; export * from "./FederationKeys"; export * from "./Guild"; export * from "./Invite"; diff --git a/src/util/util/morgan.ts b/src/util/util/morgan.ts index 93dc9ce6..31e7e9ef 100644 --- a/src/util/util/morgan.ts +++ b/src/util/util/morgan.ts @@ -2,19 +2,19 @@ import Express from "express"; import morgan from "morgan"; import { red } from "picocolors"; -let HAS_WARNED = false; -export const setupMorganLogging = (app: Express.Application) => { +let ENABLED = false; +export const setupMorganLogging = (app: Express.Router) => { const logRequests = process.env["LOG_REQUESTS"] != undefined; if (!logRequests) return; - if (!HAS_WARNED) - console.log( - red( - `Warning: Request logging is enabled! This will spam your console!\nTo disable this, unset the 'LOG_REQUESTS' environment variable!`, - ), - ); + if (ENABLED) return; + ENABLED = true; - HAS_WARNED = true; + console.log( + red( + `Warning: Request logging is enabled! This will spam your console!\nTo disable this, unset the 'LOG_REQUESTS' environment variable!`, + ), + ); app.use( morgan("combined", {