1
0
mirror of https://github.com/spacebarchat/server.git synced 2024-11-09 20:22:47 +01:00

send Follow request to guild when remote invite code used

This commit is contained in:
Madeline 2023-09-28 16:10:57 +00:00
parent 904618e0a7
commit c82b71695d
21 changed files with 378 additions and 126 deletions

23
.vscode/launch.json vendored
View File

@ -15,7 +15,28 @@
"skipFiles": ["<node_internals>/**"], "skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/src/bundle/start.ts", "program": "${workspaceFolder}/src/bundle/start.ts",
"outFiles": ["${workspaceFolder}/dist/**/*.js"], "outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "tsc: build - tsconfig.json" "preLaunchTask": "tsc: build - tsconfig.json",
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "API",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/src/api/start.ts",
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "tsc: build - tsconfig.json",
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Activitypub",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/src/activitypub/start.ts",
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "tsc: build - tsconfig.json",
"outputCapture": "std"
} }
] ]
} }

View File

@ -8,6 +8,7 @@
"start": "node dist/bundle/start.js", "start": "node dist/bundle/start.js",
"start:api": "node dist/api/start.js", "start:api": "node dist/api/start.js",
"start:cdn": "node dist/cdn/start.js", "start:cdn": "node dist/cdn/start.js",
"start:ap": "node dist/activitypub/start.js",
"start:gateway": "node dist/gateway/start.js", "start:gateway": "node dist/gateway/start.js",
"build": "tsc -p .", "build": "tsc -p .",
"test": "node scripts/test.js", "test": "node scripts/test.js",

View File

@ -1,9 +1,26 @@
# Spacebar Activitypub # Spacebar Activitypub
## Activitypub Specification
- [Activitystreams vocab](https://www.w3.org/TR/activitystreams-vocabulary) - [Activitystreams vocab](https://www.w3.org/TR/activitystreams-vocabulary)
- [Activitystreams](https://www.w3.org/TR/activitystreams-core) - [Activitystreams](https://www.w3.org/TR/activitystreams-core)
- [Activitypub spec](https://www.w3.org/TR/activitypub/) - [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 ## Supported Types
| Spacebar object | Activitypub | | Spacebar object | Activitypub |
@ -93,6 +110,8 @@ Also contains a collection of [roles](#role-federation).
### Properties Used ### Properties Used
- attributed to is owner
## User Federation ## User Federation
A person. Sends messages to Channels. May also create, modify, or moderate guilds, channels, or roles. A person. Sends messages to Channels. May also create, modify, or moderate guilds, channels, or roles.

View File

@ -1,4 +1,4 @@
import { BodyParser, CORS, ErrorHandler } from "@spacebar/api"; import { CORS, ErrorHandler } from "@spacebar/api";
import { import {
Config, Config,
JSONReplacer, JSONReplacer,
@ -13,7 +13,7 @@ import { Server, ServerOptions } from "lambert-server";
import path from "path"; import path from "path";
import wellknown from "./well-known"; import wellknown from "./well-known";
export type SpacebarServerOptions = ServerOptions; type SpacebarServerOptions = ServerOptions;
export class FederationServer extends Server { export class FederationServer extends Server {
public declare options: SpacebarServerOptions; public declare options: SpacebarServerOptions;
@ -29,18 +29,23 @@ export class FederationServer extends Server {
await Config.init(); await Config.init();
await Sentry.init(this.app); await Sentry.init(this.app);
setupMorganLogging(this.app); if (!Config.get().federation.enabled) {
this.app.set("json replacer", JSONReplacer); return;
}
console.log("Federation is enabled!");
this.app.set("json replacer", JSONReplacer);
this.app.use(CORS); this.app.use(CORS);
this.app.use(bodyParser.json({ inflate: true }));
this.app.use( this.app.use(
BodyParser({ bodyParser.json({
inflate: true,
limit: "10mb",
type: "application/activity+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 app = this.app;
const api = Router(); const api = Router();
@ -64,13 +69,6 @@ export class FederationServer extends Server {
path.join(__dirname, "routes", "/"), 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 = app;
this.app.use("/federation", api); this.app.use("/federation", api);
@ -80,6 +78,13 @@ export class FederationServer extends Server {
Sentry.errorHandler(this.app); Sentry.errorHandler(this.app);
api.use("*", (req: Request, res: Response) => {
res.status(404).json({
message: "404 endpoint not found",
code: 0,
});
});
return super.start(); return super.start();
} }
} }

View File

@ -2,6 +2,7 @@ import { Config, FederationKey, OrmUtils } from "@spacebar/util";
import { APActivity } from "activitypub-types"; import { APActivity } from "activitypub-types";
import crypto from "crypto"; import crypto from "crypto";
import { IncomingHttpHeaders } from "http"; import { IncomingHttpHeaders } from "http";
import { RequestInit } from "node-fetch";
import { APError, fetchFederatedUser, fetchOpts } from "./utils"; import { APError, fetchFederatedUser, fetchOpts } from "./utils";
export class HttpSig { export class HttpSig {
@ -27,8 +28,9 @@ export class HttpSig {
activity: APActivity, activity: APActivity,
requestHeaders: IncomingHttpHeaders, requestHeaders: IncomingHttpHeaders,
) { ) {
const sigheader = requestHeaders["signature"] as string; const sigheader = requestHeaders["signature"]?.toString();
const sigopts: { [key: string]: string } = Object.assign( if (!sigheader) throw new APError("Missing signature");
const sigopts: { [key: string]: string | undefined } = Object.assign(
{}, {},
...sigheader.split(",").flatMap((keyval) => { ...sigheader.split(",").flatMap((keyval) => {
const split = keyval.split("="); const split = keyval.split("=");
@ -43,8 +45,10 @@ export class HttpSig {
if (!signature || !headers || !keyId) if (!signature || !headers || !keyId)
throw new APError("Invalid signature"); throw new APError("Invalid signature");
const ALLOWED_ALGO = "rsa-sha256";
// If it's provided, check it. otherwise just assume it's 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}`); throw new APError(`Unsupported encryption algorithm ${algorithm}`);
const url = new URL(keyId); const url = new URL(keyId);
@ -59,7 +63,9 @@ export class HttpSig {
headers.split(/\s+/), headers.split(/\s+/),
); );
const verifier = crypto.createVerify(algorithm.toUpperCase()); const verifier = crypto.createVerify(
algorithm?.toUpperCase() || ALLOWED_ALGO,
);
verifier.write(expected); verifier.write(expected);
verifier.end(); verifier.end();
@ -113,13 +119,13 @@ export class HttpSig {
return OrmUtils.mergeDeep(fetchOpts, { return OrmUtils.mergeDeep(fetchOpts, {
method: "POST", method: "POST",
body: message, body: JSON.stringify(message),
headers: { headers: {
Host: url.hostname, Host: url.hostname,
Date: now.toUTCString(), Date: now.toUTCString(),
Digest: `SHA-256=${digest}`, Digest: `SHA-256=${digest}`,
Signature: header, Signature: header,
}, },
}); } as RequestInit);
} }
} }

View File

@ -30,7 +30,10 @@ export class Federation {
throw new APError("Invalid signature"); 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) const object = Array.isArray(activity.object)
? activity.object[0] ? activity.object[0]

View File

@ -12,14 +12,11 @@ class FederationQueue {
private queue: Map<Instance, Array<APActivity>> = new Map(); private queue: Map<Instance, Array<APActivity>> = new Map();
public async distribute(activity: APActivity) { public async distribute(activity: APActivity) {
let { to, actor } = activity; let { actor } = activity;
const { to, object } = activity;
if (!to)
throw new APError("Activity with no `to` field is undeliverable.");
if (!Array.isArray(to)) to = [to];
if (!actor) 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]; if (Array.isArray(actor)) actor = actor[0];
// TODO: check if `to` is on our instance? // TODO: check if `to` is on our instance?
@ -38,12 +35,43 @@ class FederationQueue {
return; return;
} }
for (const receiver of to) { // this is ugly
if (typeof receiver != "string") { for (let recv of [
console.error(receiver); ...(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; continue;
} }
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( const signedActivity = await HttpSig.sign(
receiver.toString(), receiver.toString(),
sender, sender,
@ -51,8 +79,12 @@ class FederationQueue {
); );
const ret = await fetch(receiver, signedActivity); const ret = await fetch(receiver, signedActivity);
if (!ret.ok) {
console.log(ret); console.error(
`Sending activity ${activity.id} to ` +
`${receiver} failed with code ${ret.status} `,
JSON.stringify(await ret.json()),
);
} }
} }
} }

View File

@ -25,6 +25,7 @@ import {
ACTIVITYSTREAMS_CONTEXT, ACTIVITYSTREAMS_CONTEXT,
APError, APError,
APObjectIsPerson, APObjectIsPerson,
fetchFederatedUser,
resolveAPObject, resolveAPObject,
} from "./utils"; } from "./utils";
@ -257,7 +258,7 @@ export const transformPersonToUser = async (person: APPerson) => {
const keys = await FederationKey.create({ const keys = await FederationKey.create({
actorId: Snowflake.generate(), actorId: Snowflake.generate(),
federatedId: url.toString(), federatedId: url.toString(),
username: person.preferredUsername, username: person.name,
domain: url.hostname, domain: url.hostname,
publicKey: person.publicKey?.publicKeyPem, publicKey: person.publicKey?.publicKeyPem,
type: ActorType.USER, type: ActorType.USER,
@ -289,18 +290,63 @@ export const transformPersonToUser = async (person: APPerson) => {
}).save(); }).save();
}; };
export const transformOrganisationToInvite = (guild: APOrganization) => { export const transformOrganisationToInvite = async (
code: string,
org: APOrganization,
) => {
const guild = await transformOrganisationToGuild(org);
return Invite.create({ return Invite.create({
code: guild.id, code,
temporary: false, temporary: false,
uses: -1, uses: -1,
max_uses: 0, max_uses: 0,
max_age: 0, max_age: 0,
created_at: new Date(0), created_at: new Date(0),
flags: 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 ( export const transformGuildToOrganisation = async (
guild: Guild, guild: Guild,
): Promise<APOrganization> => { ): Promise<APOrganization> => {
@ -317,7 +363,11 @@ export const transformGuildToOrganisation = async (
name: guild.name, name: guild.name,
preferredUsername: guild.id, 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`, inbox: `https://${host}/federation/guilds/${guild.id}/inbox`,
outbox: `https://${host}/federation/guilds/${guild.id}/outbox`, outbox: `https://${host}/federation/guilds/${guild.id}/outbox`,

View File

@ -2,6 +2,7 @@ import { DEFAULT_FETCH_OPTIONS } from "@spacebar/api";
import { import {
ActorType, ActorType,
Config, Config,
FederationActivity,
FederationKey, FederationKey,
OrmUtils, OrmUtils,
Snowflake, Snowflake,
@ -13,6 +14,7 @@ import {
APActivity, APActivity,
APAnnounce, APAnnounce,
APCreate, APCreate,
APFollow,
APNote, APNote,
APPerson, APPerson,
AnyAPObject, AnyAPObject,
@ -21,14 +23,18 @@ import { HTTPError } from "lambert-server";
import fetch from "node-fetch"; import fetch from "node-fetch";
import { ProxyAgent } from "proxy-agent"; import { ProxyAgent } from "proxy-agent";
import TurndownService from "turndown"; import TurndownService from "turndown";
import { Federation } from ".";
export const ACTIVITYSTREAMS_CONTEXT = "https://www.w3.org/ns/activitystreams"; export const ACTIVITYSTREAMS_CONTEXT = "https://www.w3.org/ns/activitystreams";
export const fetchOpts = OrmUtils.mergeDeep(DEFAULT_FETCH_OPTIONS, { export const fetchOpts = Object.freeze(
OrmUtils.mergeDeep(DEFAULT_FETCH_OPTIONS, {
headers: { headers: {
Accept: "application/activity+json", Accept: "application/activity+json",
"Content-Type": "application/activity+json",
}, },
}); }),
);
export class APError extends HTTPError {} export class APError extends HTTPError {}
@ -112,6 +118,15 @@ export const resolveWebfinger = async (
return await resolveAPObject<AnyAPObject>(link.href); return await resolveAPObject<AnyAPObject>(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 */ /** Fetch from local db, if not found fetch from remote instance and save */
export const fetchFederatedUser = async (actorId: string) => { export const fetchFederatedUser = async (actorId: string) => {
// if we were given webfinger, resolve that first // if we were given webfinger, resolve that first
@ -147,7 +162,7 @@ export const fetchFederatedUser = async (actorId: string) => {
const keys = FederationKey.create({ const keys = FederationKey.create({
actorId: Snowflake.generate(), actorId: Snowflake.generate(),
federatedId: actorId, federatedId: actorId,
username: remoteActor.preferredUsername, username: remoteActor.name,
// this is technically not correct // this is technically not correct
// but it's slightly more difficult to go from actor url -> handle // but it's slightly more difficult to go from actor url -> handle
// so thats a problem for future me // so thats a problem for future me
@ -160,7 +175,7 @@ export const fetchFederatedUser = async (actorId: string) => {
const user = User.create({ const user = User.create({
id: keys.actorId, id: keys.actorId,
username: remoteActor.preferredUsername, username: remoteActor.name,
discriminator: "0", discriminator: "0",
bio: new TurndownService().turndown(remoteActor.summary), // html -> markdown bio: new TurndownService().turndown(remoteActor.summary), // html -> markdown
email: `${remoteActor.preferredUsername}@${keys.domain}`, 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? // fetch from remote server?
export const APObjectIsPerson = (object: AnyAPObject): object is APPerson => { export const APObjectIsPerson = (object: AnyAPObject): object is APPerson => {
return "type" in object && object.type == "Person"; return "type" in object && object.type == "Person";

View File

@ -1,14 +1,10 @@
import { transformNoteToMessage } from "@spacebar/ap"; import { Federation } from "@spacebar/ap";
import { route } from "@spacebar/api"; import { route } from "@spacebar/api";
import { Message, emitEvent } from "@spacebar/util";
import { APCreate, APNote } from "activitypub-types";
import { Request, Response, Router } from "express"; import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
const router = Router(); const router = Router();
router.post("/", route({}), async (req: Request, res: Response) => { router.post("/", route({}), async (req: Request, res: Response) => {
// TODO: check if the activity exists on the remote server res.json(await Federation.genericInboxHandler(req));
// TODO: refactor
}); });
export default router; export default router;

View File

@ -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;

13
src/activitypub/start.ts Normal file
View File

@ -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;

View File

@ -406,6 +406,7 @@ router.post(
); );
setImmediate(async () => { setImmediate(async () => {
if (!Config.get().federation.enabled) return;
const ap = await transformMessageToAnnounceNoce(message); const ap = await transformMessageToAnnounceNoce(message);
await Federation.distribute(ap); await Federation.distribute(ap);

View File

@ -70,6 +70,14 @@ router.patch(
const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); 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) if (body.enabled != undefined)
guild.welcome_screen.enabled = body.enabled; guild.welcome_screen.enabled = body.enabled;

View File

@ -19,9 +19,10 @@
import { import {
APError, APError,
APObjectIsOrganisation, APObjectIsOrganisation,
resolveWebfinger,
splitQualifiedMention, splitQualifiedMention,
transformOrganisationToInvite, transformOrganisationToInvite,
tryFederatedGuildJoin,
tryResolveWebfinger,
} from "@spacebar/ap"; } from "@spacebar/ap";
import { route } from "@spacebar/api"; import { route } from "@spacebar/api";
import { import {
@ -68,17 +69,21 @@ router.get(
if (domain != accountDomain && domain != host) { if (domain != accountDomain && domain != host) {
// The domain isn't ours // The domain isn't ours
const remoteGuild = await resolveWebfinger(inputValue); const remoteGuild = await tryResolveWebfinger(inputValue);
if (remoteGuild) {
if (APObjectIsOrganisation(remoteGuild)) if (APObjectIsOrganisation(remoteGuild))
return res.json( return res.json(
transformOrganisationToInvite(remoteGuild), await transformOrganisationToInvite(
inputValue,
remoteGuild,
),
); );
throw new APError("Remote resource is not a guild"); throw new APError("Remote resource is not a guild");
} }
} }
} }
}
const invite = await Invite.findOneOrFail({ const invite = await Invite.findOneOrFail({
where: { code }, where: { code },
@ -110,8 +115,21 @@ router.post(
}), }),
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
if (req.user_bot) throw DiscordApiErrors.BOT_PROHIBITED_ENDPOINT; if (req.user_bot) throw DiscordApiErrors.BOT_PROHIBITED_ENDPOINT;
const { code } = req.params; 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({ const { guild_id } = await Invite.findOneOrFail({
where: { code: code }, where: { code: code },
}); });

View File

@ -23,7 +23,12 @@ import { FederationServer } from "@spacebar/ap";
import * as Api from "@spacebar/api"; import * as Api from "@spacebar/api";
import { CDNServer } from "@spacebar/cdn"; import { CDNServer } from "@spacebar/cdn";
import * as Gateway from "@spacebar/gateway"; 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 express from "express";
import http from "http"; import http from "http";
import { bold, green } from "picocolors"; import { bold, green } from "picocolors";
@ -35,9 +40,9 @@ const production = process.env.NODE_ENV == "development" ? false : true;
server.on("request", app); server.on("request", app);
const api = new Api.SpacebarServer({ server, port, production, 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 cdn = new CDNServer({ server, port, production, app });
const gateway = new Gateway.Server({ server, port, production }); const gateway = new Gateway.Server({ server, port, production });
const federation = new FederationServer({ server, port, production, app });
process.on("SIGTERM", async () => { process.on("SIGTERM", async () => {
console.log("Shutting down due to SIGTERM"); console.log("Shutting down due to SIGTERM");
@ -53,6 +58,8 @@ async function main() {
await Config.init(); await Config.init();
await Sentry.init(app); await Sentry.init(app);
setupMorganLogging(app);
await new Promise((resolve) => await new Promise((resolve) =>
server.listen({ port }, () => resolve(undefined)), server.listen({ port }, () => resolve(undefined)),
); );

View File

@ -16,7 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
export * from "@spacebar/ap";
export * from "@spacebar/api"; export * from "@spacebar/api";
export * from "@spacebar/util";
export * from "@spacebar/gateway";
export * from "@spacebar/cdn"; export * from "@spacebar/cdn";
export * from "@spacebar/gateway";
export * from "@spacebar/util";

View File

@ -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,
};
}
}

View File

@ -17,6 +17,7 @@
*/ */
import { import {
BeforeInsert,
Column, Column,
Entity, Entity,
JoinColumn, JoinColumn,
@ -83,8 +84,8 @@ export class Guild extends BaseClass {
@ManyToOne(() => Channel) @ManyToOne(() => Channel)
afk_channel?: Channel; afk_channel?: Channel;
@Column({ nullable: true }) @Column()
afk_timeout?: number; afk_timeout: number;
// * commented out -> use owner instead // * commented out -> use owner instead
// application id of the guild creator if it is bot-created // application id of the guild creator if it is bot-created
@ -135,11 +136,11 @@ export class Guild extends BaseClass {
@Column({ nullable: true }) @Column({ nullable: true })
max_video_channel_users?: number; max_video_channel_users?: number;
@Column({ nullable: true }) @Column({ default: 0 })
member_count?: number; member_count: number;
@Column({ nullable: true }) @Column({ nullable: true, type: Number, default: 0 })
presence_count?: number; // users online presence_count: number | null; // users online
@OneToMany(() => Member, (member: Member) => member.guild, { @OneToMany(() => Member, (member: Member) => member.guild, {
cascade: true, cascade: true,
@ -211,8 +212,8 @@ export class Guild extends BaseClass {
}) })
webhooks: Webhook[]; webhooks: Webhook[];
@Column({ nullable: true }) @Column({ default: 0 })
mfa_level?: number; mfa_level: number;
@Column() @Column()
name: string; name: string;
@ -225,14 +226,14 @@ export class Guild extends BaseClass {
@ManyToOne(() => User) @ManyToOne(() => User)
owner?: User; // optional to allow for ownerless guilds owner?: User; // optional to allow for ownerless guilds
@Column({ nullable: true }) @Column({ nullable: true, type: String, default: "en-US" })
preferred_locale?: string; preferred_locale: string | null;
@Column({ nullable: true }) @Column({ nullable: true, type: Number, default: 0 })
premium_subscription_count?: number; premium_subscription_count?: number;
@Column() @Column({ default: 0 })
premium_tier?: number; // crowd premium level premium_tier: number; // crowd premium level
@Column({ nullable: true }) @Column({ nullable: true })
@RelationId((guild: Guild) => guild.public_updates_channel) @RelationId((guild: Guild) => guild.public_updates_channel)
@ -264,17 +265,17 @@ export class Guild extends BaseClass {
@ManyToOne(() => Channel) @ManyToOne(() => Channel)
system_channel?: Channel; system_channel?: Channel;
@Column({ nullable: true }) @Column({ default: 4 }) // defaults effect: suppress the setup tips to save performance
system_channel_flags?: number; system_channel_flags?: number;
@Column() @Column()
unavailable: boolean = false; unavailable: boolean = false;
@Column({ nullable: true }) @Column({ default: 0 })
verification_level?: number; verification_level: number;
@Column({ type: "simple-json" }) @Column({ type: "simple-json", nullable: true }) // TODO: move this to own table
welcome_screen: GuildWelcomeScreen; welcome_screen: GuildWelcomeScreen | null;
@Column({ nullable: true }) @Column({ nullable: true })
@RelationId((guild: Guild) => guild.widget_channel) @RelationId((guild: Guild) => guild.widget_channel)
@ -287,8 +288,8 @@ export class Guild extends BaseClass {
@Column() @Column()
widget_enabled: boolean = true; widget_enabled: boolean = true;
@Column({ nullable: true }) @Column({ default: 0 })
nsfw_level?: number; nsfw_level: number;
@Column() @Column()
nsfw: boolean = false; nsfw: boolean = false;
@ -317,32 +318,6 @@ export class Guild extends BaseClass {
name: body.name || "Spacebar", name: body.name || "Spacebar",
icon: await handleFile(`/icons/${guild_id}`, body.icon as string), 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 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(); }).save();
// we have to create the role _after_ the guild because else we would get a "SQLITE_CONSTRAINT: FOREIGN KEY constraint failed" error // 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, 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;
};
} }

View File

@ -31,6 +31,7 @@ export * from "./ConnectionConfigEntity";
export * from "./EmbedCache"; export * from "./EmbedCache";
export * from "./Emoji"; export * from "./Emoji";
export * from "./Encryption"; export * from "./Encryption";
export * from "./FederationActivity";
export * from "./FederationKeys"; export * from "./FederationKeys";
export * from "./Guild"; export * from "./Guild";
export * from "./Invite"; export * from "./Invite";

View File

@ -2,20 +2,20 @@ import Express from "express";
import morgan from "morgan"; import morgan from "morgan";
import { red } from "picocolors"; import { red } from "picocolors";
let HAS_WARNED = false; let ENABLED = false;
export const setupMorganLogging = (app: Express.Application) => { export const setupMorganLogging = (app: Express.Router) => {
const logRequests = process.env["LOG_REQUESTS"] != undefined; const logRequests = process.env["LOG_REQUESTS"] != undefined;
if (!logRequests) return; if (!logRequests) return;
if (!HAS_WARNED) if (ENABLED) return;
ENABLED = true;
console.log( console.log(
red( red(
`Warning: Request logging is enabled! This will spam your console!\nTo disable this, unset the 'LOG_REQUESTS' environment variable!`, `Warning: Request logging is enabled! This will spam your console!\nTo disable this, unset the 'LOG_REQUESTS' environment variable!`,
), ),
); );
HAS_WARNED = true;
app.use( app.use(
morgan("combined", { morgan("combined", {
skip: (req, res) => { skip: (req, res) => {