diff --git a/package.json b/package.json
index 0cdc6d2b2..d04754c71 100644
--- a/package.json
+++ b/package.json
@@ -113,6 +113,7 @@
"ws": "^8.18.0"
},
"_moduleAliases": {
+ "@spacebar/admin-api": "dist/admin-api",
"@spacebar/api": "dist/api",
"@spacebar/cdn": "dist/cdn",
"@spacebar/gateway": "dist/gateway",
diff --git a/src/admin-api/Server.ts b/src/admin-api/Server.ts
new file mode 100644
index 000000000..aad905fb2
--- /dev/null
+++ b/src/admin-api/Server.ts
@@ -0,0 +1,190 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import {
+ Config,
+ ConnectionLoader,
+ Email,
+ JSONReplacer,
+ Sentry,
+ initDatabase,
+ initEvent,
+ registerRoutes,
+} from "@spacebar/util";
+import { Request, Response, Router, IRoute, Application } from "express";
+import { Server, ServerOptions } from "lambert-server";
+import "missing-native-js-functions";
+import morgan from "morgan";
+import path from "path";
+import { red } from "picocolors";
+import { Authentication, CORS, ImageProxy } from "./middlewares/";
+import { BodyParser } from "./middlewares/BodyParser";
+import { ErrorHandler } from "./middlewares/ErrorHandler";
+import { initRateLimits } from "./middlewares/RateLimit";
+import { initTranslation } from "./middlewares/Translation";
+import * as console from "node:console";
+import fs from "fs/promises";
+import { Dirent } from "node:fs";
+
+const PUBLIC_ASSETS_FOLDER = path.join(
+ __dirname,
+ "..",
+ "..",
+ "assets",
+ "public",
+);
+
+export type SpacebarServerOptions = ServerOptions;
+
+// declare global {
+// eslint-disable-next-line @typescript-eslint/no-namespace
+ // namespace Express {
+ // interface Request {
+ // server: AdminApiServer;
+ // }
+ // }
+// }
+
+export class AdminApiServer extends Server {
+ public declare options: SpacebarServerOptions;
+
+ constructor(opts?: Partial) {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ super({ ...opts, errorHandler: false, jsonBody: false });
+ }
+
+ async start() {
+ console.log("[AdminAPI] Starting...");
+ await initDatabase();
+ await Config.init();
+ await initEvent();
+ await Sentry.init(this.app);
+
+ const logRequests = process.env["LOG_REQUESTS"] != undefined;
+ if (logRequests) {
+ this.app.use(
+ morgan("combined", {
+ skip: (req, res) => {
+ let skip = !(
+ process.env["LOG_REQUESTS"]?.includes(
+ res.statusCode.toString(),
+ ) ?? false
+ );
+ if (process.env["LOG_REQUESTS"]?.charAt(0) == "-")
+ skip = !skip;
+ return skip;
+ },
+ }),
+ );
+ }
+
+ this.app.set("json replacer", JSONReplacer);
+
+ const trustedProxies = Config.get().security.trustedProxies;
+ if (trustedProxies) this.app.set("trust proxy", trustedProxies);
+
+ this.app.use(CORS);
+ this.app.use(BodyParser({ inflate: true, limit: "10mb" }));
+
+ const app = this.app;
+ const api = Router();
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ this.app = api;
+
+ // api.use(Authentication);
+ // await initRateLimits(api);
+ // await initTranslation(api);
+
+ // this.routes = await registerRoutes(
+ // this,
+ // path.join(__dirname, "routes", "/"),
+ // );
+
+ await this.registerControllers(app, path.join(__dirname, "routes", "/"));
+
+ // fall /v1/api back to /v0/api without redirect
+ app.use("/_spacebar/admin/:version/:path", (req, res) => {
+ console.log(req.params);
+ const versionNumber = req.params.version
+ .replace("v", "")
+ .toNumber();
+ const found = [];
+ for (let i = versionNumber; i >= 0; i--) {
+ // const oroutes = this.app._router.stack.filter(
+ // (x: IRoute) =>
+ // x.path == `/_spacebar/admin/v${i}/${req.params.path}`,
+ // );
+ const routes = this.routes.map(
+ (x: Router) =>
+ x.stack.filter(y =>
+ y.path == `/_spacebar/admin/v${i}/${req.params.path}`
+ ),
+ ).filter(x => x.length > 0);
+ console.log(i, routes);
+ found.push(...routes);
+ }
+ res.json({ versionNumber, routes: found });
+ });
+ // 404 is not an error in express, so this should not be an error middleware
+ // this is a fine place to put the 404 handler because its after we register the routes
+ // and since its not an error middleware, our error handler below still works.
+ api.use("*", (req: Request, res: Response) => {
+ res.status(404).json({
+ message: "404 endpoint not found",
+ code: 0,
+ });
+ });
+
+ this.app = app;
+
+ app.use("/_spacebar/admin/", api);
+
+ this.app.use(ErrorHandler);
+
+ Sentry.errorHandler(this.app);
+
+ ConnectionLoader.loadConnections();
+
+ if (logRequests)
+ console.log(
+ red(
+ `Warning: Request logging is enabled! This will spam your console!\nTo disable this, unset the 'LOG_REQUESTS' environment variable!`,
+ ),
+ );
+
+ console.log("[AdminAPI] Listening...");
+ return super.start();
+ }
+
+ private async registerControllers(app: Application, root: string) {
+ // get files recursively
+ const fsEntries = (await fs.readdir(root, { withFileTypes: true }));
+ for (const file of fsEntries.filter(x=>x.isFile() && (x.name.endsWith(".js") || x.name.endsWith(".ts")))) {
+ const fullPath = path.join(file.parentPath, file.name);
+ const controller = require(fullPath);
+ console.log(fullPath, controller);
+
+ }
+
+ for (const dir of fsEntries.filter(x=>x.isDirectory())) {
+ await this.registerControllers(app, path.join(dir.parentPath, dir.name));
+ }
+ }
+}
diff --git a/src/admin-api/global.d.ts b/src/admin-api/global.d.ts
new file mode 100644
index 000000000..9423b781a
--- /dev/null
+++ b/src/admin-api/global.d.ts
@@ -0,0 +1,26 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+// declare global {
+// namespace Express {
+// interface Request {
+// user_id: any;
+// token: any;
+// }
+// }
+// }
diff --git a/src/admin-api/index.ts b/src/admin-api/index.ts
new file mode 100644
index 000000000..fee28e707
--- /dev/null
+++ b/src/admin-api/index.ts
@@ -0,0 +1,21 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+export * from "./Server";
+export * from "./middlewares/";
+export * from "./util/";
diff --git a/src/admin-api/middlewares/Authentication.ts b/src/admin-api/middlewares/Authentication.ts
new file mode 100644
index 000000000..b4f484776
--- /dev/null
+++ b/src/admin-api/middlewares/Authentication.ts
@@ -0,0 +1,113 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import * as Sentry from "@sentry/node";
+import { checkToken, Rights } from "@spacebar/util";
+import { NextFunction, Request, Response } from "express";
+import { HTTPError } from "lambert-server";
+
+export const NO_AUTHORIZATION_ROUTES = [
+ // Authentication routes
+ "POST /auth/login",
+ "POST /auth/register",
+ "GET /auth/location-metadata",
+ "POST /auth/mfa/",
+ "POST /auth/verify",
+ "POST /auth/forgot",
+ "POST /auth/reset",
+ "GET /invites/",
+ // Routes with a seperate auth system
+ /^(POST|HEAD) \/webhooks\/\d+\/\w+\/?/, // no token requires auth
+ // Public information endpoints
+ "GET /ping",
+ "GET /gateway",
+ "GET /experiments",
+ "GET /updates",
+ "GET /download",
+ "GET /scheduled-maintenances/upcoming.json",
+ // Public kubernetes integration
+ "GET /-/readyz",
+ "GET /-/healthz",
+ // Client analytics
+ "POST /science",
+ "POST /track",
+ // Public policy pages
+ "GET /policies/instance/",
+ // Oauth callback
+ "/oauth2/callback",
+ // Asset delivery
+ /^(GET|HEAD) \/guilds\/\d+\/widget\.(json|png)/,
+ // Connections
+ /^(POST|HEAD) \/connections\/\w+\/callback/,
+ // Image proxy
+ /^(GET|HEAD) \/imageproxy\/[A-Za-z0-9+/]\/\d+x\d+\/.+/,
+];
+
+export const API_PREFIX = /^\/api(\/v\d+)?/;
+export const API_PREFIX_TRAILING_SLASH = /^\/api(\/v\d+)?\//;
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Express {
+ interface Request {
+ user_id: string;
+ user_bot: boolean;
+ token: { id: string; iat: number };
+ rights: Rights;
+ }
+ }
+}
+
+export async function Authentication(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ if (req.method === "OPTIONS") return res.sendStatus(204);
+ const url = req.url.replace(API_PREFIX, "");
+ if (
+ NO_AUTHORIZATION_ROUTES.some((x) => {
+ if (req.method == "HEAD") {
+ if (typeof x === "string")
+ return url.startsWith(x.split(" ").slice(1).join(" "));
+ return x.test(req.method + " " + url);
+ }
+
+ if (typeof x === "string")
+ return (req.method + " " + url).startsWith(x);
+ return x.test(req.method + " " + url);
+ })
+ )
+ return next();
+ if (!req.headers.authorization)
+ return next(new HTTPError("Missing Authorization Header", 401));
+
+ Sentry.setUser({ id: req.user_id });
+
+ try {
+ const { decoded, user } = await checkToken(req.headers.authorization);
+
+ req.token = decoded;
+ req.user_id = decoded.id;
+ req.user_bot = user.bot;
+ req.rights = new Rights(Number(user.rights));
+ return next();
+ } catch (error) {
+ return next(new HTTPError(error!.toString(), 400));
+ }
+}
diff --git a/src/admin-api/middlewares/BodyParser.ts b/src/admin-api/middlewares/BodyParser.ts
new file mode 100644
index 000000000..34433aeb2
--- /dev/null
+++ b/src/admin-api/middlewares/BodyParser.ts
@@ -0,0 +1,58 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import bodyParser, { OptionsJson } from "body-parser";
+import { NextFunction, Request, Response } from "express";
+import { HTTPError } from "lambert-server";
+
+const errorMessages: { [key: string]: [string, number] } = {
+ "entity.too.large": ["Request body too large", 413],
+ "entity.parse.failed": ["Invalid JSON body", 400],
+ "entity.verify.failed": ["Entity verification failed", 403],
+ "request.aborted": ["Request aborted", 400],
+ "request.size.invalid": ["Request size did not match content length", 400],
+ "stream.encoding.set": ["Stream encoding should not be set", 500],
+ "stream.not.readable": ["Stream is not readable", 500],
+ "parameters.too.many": ["Too many parameters", 413],
+ "charset.unsupported": ["Unsupported charset", 415],
+ "encoding.unsupported": ["Unsupported content encoding", 415],
+};
+
+export function BodyParser(opts?: OptionsJson) {
+ const jsonParser = bodyParser.json(opts);
+
+ return (req: Request, res: Response, next: NextFunction) => {
+ if (!req.headers["content-type"])
+ req.headers["content-type"] = "application/json";
+
+ jsonParser(req, res, (err) => {
+ if (err) {
+ const [message, status] = errorMessages[err.type] || [
+ "Invalid Body",
+ 400,
+ ];
+ const errorMessage =
+ message.includes("charset") || message.includes("encoding")
+ ? `${message} "${err.charset || err.encoding}"`
+ : message;
+ return next(new HTTPError(errorMessage, status));
+ }
+ next();
+ });
+ };
+}
diff --git a/src/admin-api/middlewares/CORS.ts b/src/admin-api/middlewares/CORS.ts
new file mode 100644
index 000000000..3e7452fc0
--- /dev/null
+++ b/src/admin-api/middlewares/CORS.ts
@@ -0,0 +1,40 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { NextFunction, Request, Response } from "express";
+
+// TODO: config settings
+
+export function CORS(req: Request, res: Response, next: NextFunction) {
+ res.set("Access-Control-Allow-Origin", "*");
+ // TODO: use better CSP
+ res.set(
+ "Content-security-policy",
+ "default-src * data: blob: filesystem: about: ws: wss: 'unsafe-inline' 'unsafe-eval'; script-src * data: blob: 'unsafe-inline' 'unsafe-eval'; connect-src * data: blob: 'unsafe-inline'; img-src * data: blob: 'unsafe-inline'; frame-src * data: blob: ; style-src * data: blob: 'unsafe-inline'; font-src * data: blob: 'unsafe-inline';",
+ );
+ res.set(
+ "Access-Control-Allow-Headers",
+ req.header("Access-Control-Request-Headers") || "*",
+ );
+ res.set(
+ "Access-Control-Allow-Methods",
+ req.header("Access-Control-Request-Methods") || "*",
+ );
+
+ next();
+}
diff --git a/src/admin-api/middlewares/ErrorHandler.ts b/src/admin-api/middlewares/ErrorHandler.ts
new file mode 100644
index 000000000..c417e64ff
--- /dev/null
+++ b/src/admin-api/middlewares/ErrorHandler.ts
@@ -0,0 +1,82 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { NextFunction, Request, Response } from "express";
+import { HTTPError } from "lambert-server";
+import { ApiError, FieldError } from "@spacebar/util";
+const EntityNotFoundErrorRegex = /"(\w+)"/;
+
+export function ErrorHandler(
+ error: Error & { type?: string },
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ if (!error) return next();
+
+ try {
+ let code = 400;
+ let httpcode = code;
+ let message = error?.toString();
+ let errors = undefined;
+
+ if (error instanceof HTTPError && error.code)
+ code = httpcode = error.code;
+ else if (error instanceof ApiError) {
+ code = error.code;
+ message = error.message;
+ httpcode = error.httpStatus;
+ } else if (error.name === "EntityNotFoundError") {
+ message = `${
+ error.message.match(EntityNotFoundErrorRegex)?.[1] || "Item"
+ } could not be found`;
+ code = httpcode = 404;
+ } else if (error instanceof FieldError) {
+ code = Number(error.code);
+ message = error.message;
+ errors = error.errors;
+ } else if (error?.type == "entity.parse.failed") {
+ // body-parser failed
+ httpcode = 400;
+ code = 50109;
+ message = "The request body contains invalid JSON.";
+ } else {
+ console.error(
+ `[Error] ${code} ${req.url}\n`,
+ errors || error,
+ "\nbody:",
+ req.body,
+ );
+
+ if (req.server?.options?.production) {
+ // don't expose internal errors to the user, instead human errors should be thrown as HTTPError
+ message = "Internal Server Error";
+ }
+ code = httpcode = 500;
+ }
+
+ if (httpcode > 511) httpcode = 400;
+
+ res.status(httpcode).json({ code: code, message, errors });
+ } catch (error) {
+ console.error(`[Internal Server Error] 500`, error);
+ return res
+ .status(500)
+ .json({ code: 500, message: "Internal Server Error" });
+ }
+}
diff --git a/src/admin-api/middlewares/ImageProxy.ts b/src/admin-api/middlewares/ImageProxy.ts
new file mode 100644
index 000000000..deb48bd5b
--- /dev/null
+++ b/src/admin-api/middlewares/ImageProxy.ts
@@ -0,0 +1,185 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { Config, JimpType } from "@spacebar/util";
+import { Request, Response } from "express";
+import { yellow } from "picocolors";
+import crypto from "crypto";
+import fetch from "node-fetch-commonjs";
+
+let sharp: undefined | false | { default: typeof import("sharp") } = undefined;
+
+let Jimp: JimpType | undefined = undefined;
+try {
+ Jimp = require("jimp") as JimpType;
+} catch {
+ // empty
+}
+
+let sentImageProxyWarning = false;
+
+const sharpSupported = new Set([
+ "image/jpeg",
+ "image/png",
+ "image/bmp",
+ "image/tiff",
+ "image/gif",
+ "image/webp",
+ "image/avif",
+ "image/svg+xml",
+]);
+const jimpSupported = new Set([
+ "image/jpeg",
+ "image/png",
+ "image/bmp",
+ "image/tiff",
+ "image/gif",
+]);
+const resizeSupported = new Set([...sharpSupported, ...jimpSupported]);
+
+export async function ImageProxy(req: Request, res: Response) {
+ const path = req.originalUrl.split("/").slice(2);
+
+ // src/api/util/utility/EmbedHandlers.ts getProxyUrl
+ const hash = crypto
+ .createHmac("sha1", Config.get().security.requestSignature)
+ .update(path.slice(1).join("/"))
+ .digest("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+
+ try {
+ if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(path[0])))
+ throw new Error("Invalid signature");
+ } catch {
+ console.log(
+ "[ImageProxy] Invalid signature, expected " +
+ hash +
+ " but got " +
+ path[0],
+ );
+ res.status(403).send("Invalid signature");
+ return;
+ }
+
+ const abort = new AbortController();
+ setTimeout(() => abort.abort(), 5000);
+
+ const request = await fetch("https://" + path.slice(2).join("/"), {
+ headers: {
+ "User-Agent": "SpacebarImageProxy/1.0.0 (https://spacebar.chat)",
+ },
+ signal: abort.signal,
+ }).catch((e) => {
+ if (e.name === "AbortError") res.status(504).send("Request timed out");
+ else res.status(500).send("Unable to proxy origin: " + e.message);
+ });
+ if (!request) return;
+
+ if (request.status !== 200) {
+ res.status(request.status).send(
+ "Origin failed to respond: " +
+ request.status +
+ " " +
+ request.statusText,
+ );
+ return;
+ }
+
+ if (
+ !request.headers.get("Content-Type") ||
+ !request.headers.get("Content-Length")
+ ) {
+ res.status(500).send(
+ "Origin did not provide a Content-Type or Content-Length header",
+ );
+ return;
+ }
+
+ // @ts-expect-error TS doesn't believe that the header cannot be null (it's checked for falsiness above)
+ if (parseInt(request.headers.get("Content-Length")) > 1024 * 1024 * 10) {
+ res.status(500).send(
+ "Origin provided a Content-Length header that is too large",
+ );
+ return;
+ }
+
+ // @ts-expect-error TS doesn't believe that the header cannot be null (it's checked for falsiness above)
+ let contentType: string = request.headers.get("Content-Type");
+
+ const arrayBuffer = await request.arrayBuffer();
+ let resultBuffer = Buffer.from(arrayBuffer);
+
+ if (
+ !sentImageProxyWarning &&
+ resizeSupported.has(contentType) &&
+ /^\d+x\d+$/.test(path[1])
+ ) {
+ if (sharp !== false) {
+ try {
+ sharp = await import("sharp");
+ } catch {
+ sharp = false;
+ }
+ }
+
+ if (sharp === false && !Jimp) {
+ try {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore Typings don't fit
+ Jimp = await import("jimp");
+ } catch {
+ sentImageProxyWarning = true;
+ console.log(
+ `[ImageProxy] ${yellow(
+ 'Neither "sharp" or "jimp" NPM packages are installed, image resizing will be disabled',
+ )}`,
+ );
+ }
+ }
+
+ const [width, height] = path[1].split("x").map((x) => parseInt(x));
+
+ const buffer = Buffer.from(arrayBuffer);
+ if (sharp && sharpSupported.has(contentType)) {
+ resultBuffer = await sharp
+ .default(buffer)
+ // Sharp doesn't support "scaleToFit"
+ .resize(width)
+ .toBuffer();
+ } else if (Jimp && jimpSupported.has(contentType)) {
+ resultBuffer = await Jimp.read(buffer).then((image) => {
+ contentType = image.getMIME();
+ return (
+ image
+ .scaleToFit(width, height)
+ // @ts-expect-error Jimp is defined at this point
+ .getBufferAsync(Jimp.AUTO)
+ );
+ });
+ }
+ }
+
+ res.header("Content-Type", contentType);
+ res.setHeader(
+ "Cache-Control",
+ "public, max-age=" + Config.get().cdn.proxyCacheHeaderSeconds,
+ );
+
+ res.send(resultBuffer);
+}
diff --git a/src/admin-api/middlewares/RateLimit.ts b/src/admin-api/middlewares/RateLimit.ts
new file mode 100644
index 000000000..f5bfbb4f0
--- /dev/null
+++ b/src/admin-api/middlewares/RateLimit.ts
@@ -0,0 +1,272 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { getIpAdress } from "@spacebar/api";
+import { Config, getRights, listenEvent } from "@spacebar/util";
+import { NextFunction, Request, Response, Router } from "express";
+import { API_PREFIX_TRAILING_SLASH } from "./Authentication";
+
+// Docs: https://discord.com/developers/docs/topics/rate-limits
+
+// TODO: use better caching (e.g. redis) as else it creates to much pressure on the database
+
+/*
+? bucket limit? Max actions/sec per bucket?
+(ANSWER: a small spacebar instance might not need a complex rate limiting system)
+TODO: delay database requests to include multiple queries
+TODO: different for methods (GET/POST)
+> IP addresses that make too many invalid HTTP requests are automatically and temporarily restricted from accessing the Discord API. Currently, this limit is 10,000 per 10 minutes. An invalid request is one that results in 401, 403, or 429 statuses.
+> All bots can make up to 50 requests per second to our API. This is independent of any individual rate limit on a route. If your bot gets big enough, based on its functionality, it may be impossible to stay below 50 requests per second during normal operations.
+*/
+
+type RateLimit = {
+ id: "global" | "error" | string;
+ executor_id: string;
+ hits: number;
+ blocked: boolean;
+ expires_at: Date;
+};
+
+const Cache = new Map();
+const EventRateLimit = "RATELIMIT";
+
+export default function rateLimit(opts: {
+ bucket?: string;
+ window: number;
+ count: number;
+ bot?: number;
+ webhook?: number;
+ oauth?: number;
+ GET?: number;
+ MODIFY?: number;
+ error?: boolean;
+ success?: boolean;
+ onlyIp?: boolean;
+}) {
+ return async (req: Request, res: Response, next: NextFunction) => {
+ // exempt user? if so, immediately short circuit
+ if (req.user_id) {
+ const rights = await getRights(req.user_id);
+ if (rights.has("BYPASS_RATE_LIMITS")) return next();
+ }
+
+ const bucket_id =
+ opts.bucket ||
+ req.originalUrl.replace(API_PREFIX_TRAILING_SLASH, "");
+ let executor_id = getIpAdress(req);
+ if (!opts.onlyIp && req.user_id) executor_id = req.user_id;
+
+ let max_hits = opts.count;
+ if (opts.bot && req.user_bot) max_hits = opts.bot;
+ if (opts.GET && ["GET", "OPTIONS", "HEAD"].includes(req.method))
+ max_hits = opts.GET;
+ else if (
+ opts.MODIFY &&
+ ["POST", "DELETE", "PATCH", "PUT"].includes(req.method)
+ )
+ max_hits = opts.MODIFY;
+
+ const offender = Cache.get(executor_id + bucket_id);
+
+ res.set("X-RateLimit-Limit", `${max_hits}`)
+ .set("X-RateLimit-Remaining", `${max_hits - (offender?.hits || 0)}`)
+ .set("X-RateLimit-Bucket", `${bucket_id}`)
+ // assuming we aren't blocked, a new window will start after this request
+ .set("X-RateLimit-Reset", `${Date.now() + opts.window}`)
+ .set("X-RateLimit-Reset-After", `${opts.window}`);
+
+ if (offender) {
+ let reset = offender.expires_at.getTime();
+ let resetAfterMs = reset - Date.now();
+ let resetAfterSec = Math.ceil(resetAfterMs / 1000);
+
+ if (resetAfterMs <= 0) {
+ offender.hits = 0;
+ offender.expires_at = new Date(Date.now() + opts.window * 1000);
+ offender.blocked = false;
+
+ Cache.delete(executor_id + bucket_id);
+ }
+
+ res.set("X-RateLimit-Reset", `${reset}`);
+ res.set(
+ "X-RateLimit-Reset-After",
+ `${Math.max(0, Math.ceil(resetAfterSec))}`,
+ );
+
+ if (offender.blocked) {
+ const global = bucket_id === "global";
+ // each block violation pushes the expiry one full window further
+ reset += opts.window * 1000;
+ offender.expires_at = new Date(
+ offender.expires_at.getTime() + opts.window * 1000,
+ );
+ resetAfterMs = reset - Date.now();
+ resetAfterSec = Math.ceil(resetAfterMs / 1000);
+
+ console.log(`blocked bucket: ${bucket_id} ${executor_id}`, {
+ resetAfterMs,
+ });
+
+ if (global) res.set("X-RateLimit-Global", "true");
+
+ return (
+ res
+ .status(429)
+ .set("X-RateLimit-Remaining", "0")
+ .set(
+ "Retry-After",
+ `${Math.max(0, Math.ceil(resetAfterSec))}`,
+ )
+ // TODO: error rate limit message translation
+ .send({
+ message: "You are being rate limited.",
+ retry_after: resetAfterSec,
+ global,
+ })
+ );
+ }
+ }
+
+ next();
+ const hitRouteOpts = {
+ bucket_id,
+ executor_id,
+ max_hits,
+ window: opts.window,
+ };
+
+ if (opts.error || opts.success) {
+ res.once("finish", () => {
+ // check if error and increment error rate limit
+ if (res.statusCode >= 400 && opts.error) {
+ return hitRoute(hitRouteOpts);
+ } else if (
+ res.statusCode >= 200 &&
+ res.statusCode < 300 &&
+ opts.success
+ ) {
+ return hitRoute(hitRouteOpts);
+ }
+ });
+ } else {
+ return hitRoute(hitRouteOpts);
+ }
+ };
+}
+
+export async function initRateLimits(app: Router) {
+ const { routes, global, ip, error, enabled } = Config.get().limits.rate;
+ if (!enabled) return;
+ console.log("Enabling rate limits...");
+ await listenEvent(EventRateLimit, (event) => {
+ Cache.set(event.channel_id as string, event.data);
+ event.acknowledge?.();
+ });
+ // await RateLimit.delete({ expires_at: LessThan(new Date().toISOString()) }); // cleans up if not already deleted, morethan -> older date
+ // const limits = await RateLimit.find({ blocked: true });
+ // limits.forEach((limit) => {
+ // Cache.set(limit.executor_id, limit);
+ // });
+
+ setInterval(() => {
+ Cache.forEach((x, key) => {
+ if (new Date() > x.expires_at) {
+ Cache.delete(key);
+ // RateLimit.delete({ executor_id: key });
+ }
+ });
+ }, 1000 * 60);
+
+ app.use(
+ rateLimit({
+ bucket: "global",
+ onlyIp: true,
+ ...ip,
+ }),
+ );
+ app.use(rateLimit({ bucket: "global", ...global }));
+ app.use(
+ rateLimit({
+ bucket: "error",
+ error: true,
+ onlyIp: true,
+ ...error,
+ }),
+ );
+ app.use("/guilds/:id", rateLimit(routes.guild));
+ app.use("/webhooks/:id", rateLimit(routes.webhook));
+ app.use("/channels/:id", rateLimit(routes.channel));
+ app.use("/auth/login", rateLimit(routes.auth.login));
+ app.use(
+ "/auth/register",
+ rateLimit({ onlyIp: true, success: true, ...routes.auth.register }),
+ );
+}
+
+async function hitRoute(opts: {
+ executor_id: string;
+ bucket_id: string;
+ max_hits: number;
+ window: number;
+}) {
+ const id = opts.executor_id + opts.bucket_id;
+ let limit = Cache.get(id);
+ if (!limit) {
+ limit = {
+ id: opts.bucket_id,
+ executor_id: opts.executor_id,
+ expires_at: new Date(Date.now() + opts.window * 1000),
+ hits: 0,
+ blocked: false,
+ };
+ Cache.set(id, limit);
+ }
+
+ limit.hits++;
+ if (limit.hits >= opts.max_hits) {
+ limit.blocked = true;
+ }
+
+ /*
+ let ratelimit = await RateLimit.findOne({ where: { id: opts.bucket_id, executor_id: opts.executor_id } });
+ if (!ratelimit) {
+ ratelimit = new RateLimit({
+ id: opts.bucket_id,
+ executor_id: opts.executor_id,
+ expires_at: new Date(Date.now() + opts.window * 1000),
+ hits: 0,
+ blocked: false
+ });
+ }
+ ratelimit.hits++;
+ const updateBlock = !ratelimit.blocked && ratelimit.hits >= opts.max_hits;
+ if (updateBlock) {
+ ratelimit.blocked = true;
+ Cache.set(opts.executor_id + opts.bucket_id, ratelimit);
+ await emitEvent({
+ channel_id: EventRateLimit,
+ event: EventRateLimit,
+ data: ratelimit
+ });
+ } else {
+ Cache.delete(opts.executor_id);
+ }
+ await ratelimit.save();
+ */
+}
diff --git a/src/admin-api/middlewares/Translation.ts b/src/admin-api/middlewares/Translation.ts
new file mode 100644
index 000000000..f3a4c8dfe
--- /dev/null
+++ b/src/admin-api/middlewares/Translation.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import fs from "fs";
+import path from "path";
+import i18next from "i18next";
+import i18nextMiddleware from "i18next-http-middleware";
+import i18nextBackend from "i18next-fs-backend";
+import { Router } from "express";
+
+const ASSET_FOLDER_PATH = path.join(__dirname, "..", "..", "..", "assets");
+
+export async function initTranslation(router: Router) {
+ const languages = fs.readdirSync(path.join(ASSET_FOLDER_PATH, "locales"));
+ const namespaces = fs.readdirSync(
+ path.join(ASSET_FOLDER_PATH, "locales", "en"),
+ );
+ const ns = namespaces
+ .filter((x) => x.endsWith(".json"))
+ .map((x) => x.slice(0, x.length - 5));
+
+ await i18next
+ .use(i18nextBackend)
+ .use(i18nextMiddleware.LanguageDetector)
+ .init({
+ preload: languages,
+ // debug: true,
+ fallbackLng: "en",
+ ns,
+ backend: {
+ loadPath:
+ path.join(ASSET_FOLDER_PATH, "locales") +
+ "/{{lng}}/{{ns}}.json",
+ },
+ load: "all",
+ });
+
+ router.use(i18nextMiddleware.handle(i18next, {}));
+}
diff --git a/src/admin-api/middlewares/index.ts b/src/admin-api/middlewares/index.ts
new file mode 100644
index 000000000..9fd617f64
--- /dev/null
+++ b/src/admin-api/middlewares/index.ts
@@ -0,0 +1,24 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+export * from "./Authentication";
+export * from "./BodyParser";
+export * from "./CORS";
+export * from "./ErrorHandler";
+export * from "./RateLimit";
+export * from "./ImageProxy";
diff --git a/src/admin-api/routes/-/healthz.ts b/src/admin-api/routes/-/healthz.ts
new file mode 100644
index 000000000..6a2f65de3
--- /dev/null
+++ b/src/admin-api/routes/-/healthz.ts
@@ -0,0 +1,31 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { Router, Response, Request } from "express";
+import { route } from "@spacebar/api";
+import { getDatabase } from "@spacebar/util";
+
+const router = Router();
+
+router.get("/", route({}), (req: Request, res: Response) => {
+ if (!getDatabase()) return res.sendStatus(503);
+
+ return res.sendStatus(200);
+});
+
+export default router;
diff --git a/src/admin-api/routes/-/readyz.ts b/src/admin-api/routes/-/readyz.ts
new file mode 100644
index 000000000..6a2f65de3
--- /dev/null
+++ b/src/admin-api/routes/-/readyz.ts
@@ -0,0 +1,31 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { Router, Response, Request } from "express";
+import { route } from "@spacebar/api";
+import { getDatabase } from "@spacebar/util";
+
+const router = Router();
+
+router.get("/", route({}), (req: Request, res: Response) => {
+ if (!getDatabase()) return res.sendStatus(503);
+
+ return res.sendStatus(200);
+});
+
+export default router;
diff --git a/src/admin-api/routes/v0/ping.ts b/src/admin-api/routes/v0/ping.ts
new file mode 100644
index 000000000..672681f15
--- /dev/null
+++ b/src/admin-api/routes/v0/ping.ts
@@ -0,0 +1,103 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+declare const Get: any;
+declare const Responses: any;
+
+export default class PingController {
+ // constructor(public router: Router) {
+ // router.get(
+ // "/",
+ // route({
+ // responses: {
+ // 200: {
+ // body: "InstancePingResponse",
+ // },
+ // },
+ // }),
+ // this.ping,
+ // );
+ // }
+
+@Get("/ping")
+@Responses({
+ 200: {
+ body: "InstancePingResponse",
+ },
+})
+async ping(req: Request, res: Response) {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+}
+
+
+}
+
+// const router = Router();
+// export const path = "/ping";
+//
+// router.get(
+// "/",
+// route({
+// responses: {
+// 200: {
+// body: "InstancePingResponse",
+// },
+// },
+// }),
+// (req: Request, res: Response) => {
+// const { general } = Config.get();
+// res.send({
+// ping: "pong!",
+// instance: {
+// id: general.instanceId,
+// name: general.instanceName,
+// description: general.instanceDescription,
+// image: general.image,
+//
+// correspondenceEmail: general.correspondenceEmail,
+// correspondenceUserID: general.correspondenceUserID,
+//
+// frontPage: general.frontPage,
+// tosPage: general.tosPage,
+// },
+// });
+// },
+// );
+//
+// export default router;
diff --git a/src/admin-api/routes/v1/ping.ts b/src/admin-api/routes/v1/ping.ts
new file mode 100644
index 000000000..e08c38c34
--- /dev/null
+++ b/src/admin-api/routes/v1/ping.ts
@@ -0,0 +1,55 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+export const path = "/ping";
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/routes/v10/ping.ts b/src/admin-api/routes/v10/ping.ts
new file mode 100644
index 000000000..73330239b
--- /dev/null
+++ b/src/admin-api/routes/v10/ping.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/routes/v2/ping.ts b/src/admin-api/routes/v2/ping.ts
new file mode 100644
index 000000000..73330239b
--- /dev/null
+++ b/src/admin-api/routes/v2/ping.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/routes/v3/ping.ts b/src/admin-api/routes/v3/ping.ts
new file mode 100644
index 000000000..73330239b
--- /dev/null
+++ b/src/admin-api/routes/v3/ping.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/routes/v4/ping.ts b/src/admin-api/routes/v4/ping.ts
new file mode 100644
index 000000000..73330239b
--- /dev/null
+++ b/src/admin-api/routes/v4/ping.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/routes/v5/ping.ts b/src/admin-api/routes/v5/ping.ts
new file mode 100644
index 000000000..73330239b
--- /dev/null
+++ b/src/admin-api/routes/v5/ping.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/routes/v6/ping.ts b/src/admin-api/routes/v6/ping.ts
new file mode 100644
index 000000000..73330239b
--- /dev/null
+++ b/src/admin-api/routes/v6/ping.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/routes/v7/ping.ts b/src/admin-api/routes/v7/ping.ts
new file mode 100644
index 000000000..73330239b
--- /dev/null
+++ b/src/admin-api/routes/v7/ping.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/routes/v8/ping.ts b/src/admin-api/routes/v8/ping.ts
new file mode 100644
index 000000000..73330239b
--- /dev/null
+++ b/src/admin-api/routes/v8/ping.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/routes/v9/ping.ts b/src/admin-api/routes/v9/ping.ts
new file mode 100644
index 000000000..73330239b
--- /dev/null
+++ b/src/admin-api/routes/v9/ping.ts
@@ -0,0 +1,54 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+
+const router = Router();
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "InstancePingResponse",
+ },
+ },
+ }),
+ (req: Request, res: Response) => {
+ const { general } = Config.get();
+ res.send({
+ ping: "pong!",
+ instance: {
+ id: general.instanceId,
+ name: general.instanceName,
+ description: general.instanceDescription,
+ image: general.image,
+
+ correspondenceEmail: general.correspondenceEmail,
+ correspondenceUserID: general.correspondenceUserID,
+
+ frontPage: general.frontPage,
+ tosPage: general.tosPage,
+ },
+ });
+ },
+);
+
+export default router;
diff --git a/src/admin-api/start.ts b/src/admin-api/start.ts
new file mode 100644
index 000000000..7089f4633
--- /dev/null
+++ b/src/admin-api/start.ts
@@ -0,0 +1,58 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import moduleAlias from "module-alias";
+moduleAlias(__dirname + "../../../package.json");
+process.on("uncaughtException", console.error);
+process.on("unhandledRejection", console.error);
+
+import "missing-native-js-functions";
+import { config } from "dotenv";
+config();
+import { AdminApiServer } from "./Server";
+import cluster from "cluster";
+import os from "os";
+let cores = 1;
+try {
+ cores = Number(process.env.THREADS) || os.cpus().length;
+} catch {
+ console.log("[API] Failed to get thread count! Using 1...");
+}
+
+if (cluster.isPrimary && process.env.NODE_ENV == "production") {
+ console.log(`Primary ${process.pid} is running`);
+
+ // Fork workers.
+ for (let i = 0; i < cores; i++) {
+ cluster.fork();
+ }
+
+ cluster.on("exit", (worker) => {
+ console.log(`worker ${worker.process.pid} died, restart worker`);
+ cluster.fork();
+ });
+} else {
+ const port = Number(process.env.PORT) || 3001;
+
+ const server = new AdminApiServer({ port });
+ server.start().catch(console.error);
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ global.server = server;
+}
diff --git a/src/admin-api/util/handlers/Instance.ts b/src/admin-api/util/handlers/Instance.ts
new file mode 100644
index 000000000..ccd56d928
--- /dev/null
+++ b/src/admin-api/util/handlers/Instance.ts
@@ -0,0 +1,38 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { Session } from "@spacebar/util";
+
+export async function initInstance() {
+ // TODO: clean up database and delete tombstone data
+ // TODO: set first user as instance administrator/or generate one if none exists and output it in the terminal
+
+ // create default guild and add it to auto join
+ // TODO: check if any current user is not part of autoJoinGuilds
+ // const { autoJoin } = Config.get().guild;
+
+ // if (autoJoin.enabled && !autoJoin.guilds?.length) {
+ // const guild = await Guild.findOne({ where: {}, select: ["id"] });
+ // if (guild) {
+ // await Config.set({ guild: { autoJoin: { guilds: [guild.id] } } });
+ // }
+ // }
+
+ // TODO: do no clear sessions for instance cluster
+ await Session.delete({});
+}
diff --git a/src/admin-api/util/handlers/Message.ts b/src/admin-api/util/handlers/Message.ts
new file mode 100644
index 000000000..1733f7cbc
--- /dev/null
+++ b/src/admin-api/util/handlers/Message.ts
@@ -0,0 +1,378 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import * as Sentry from "@sentry/node";
+import { EmbedHandlers } from "@spacebar/api";
+import {
+ Application,
+ Attachment,
+ Channel,
+ Config,
+ Embed,
+ EmbedCache,
+ emitEvent,
+ EVERYONE_MENTION,
+ getPermission,
+ getRights,
+ Guild,
+ HERE_MENTION,
+ Message,
+ MessageCreateEvent,
+ MessageCreateSchema,
+ MessageType,
+ MessageUpdateEvent,
+ Role,
+ ROLE_MENTION,
+ Sticker,
+ User,
+ //CHANNEL_MENTION,
+ USER_MENTION,
+ Webhook,
+ handleFile,
+ Permissions,
+} from "@spacebar/util";
+import { HTTPError } from "lambert-server";
+import { In } from "typeorm";
+import fetch from "node-fetch-commonjs";
+const allow_empty = false;
+// TODO: check webhook, application, system author, stickers
+// TODO: embed gifs/videos/images
+
+const LINK_REGEX =
+ /?/g;
+
+export async function handleMessage(opts: MessageOptions): Promise {
+ const channel = await Channel.findOneOrFail({
+ where: { id: opts.channel_id },
+ relations: ["recipients"],
+ });
+ if (!channel || !opts.channel_id)
+ throw new HTTPError("Channel not found", 404);
+
+ const stickers = opts.sticker_ids
+ ? await Sticker.find({ where: { id: In(opts.sticker_ids) } })
+ : undefined;
+ const message = Message.create({
+ ...opts,
+ poll: opts.poll,
+ sticker_items: stickers,
+ guild_id: channel.guild_id,
+ channel_id: opts.channel_id,
+ attachments: opts.attachments || [],
+ embeds: opts.embeds || [],
+ reactions: /*opts.reactions ||*/ [],
+ type: opts.type ?? 0,
+ });
+
+ if (
+ message.content &&
+ message.content.length > Config.get().limits.message.maxCharacters
+ ) {
+ throw new HTTPError("Content length over max character limit");
+ }
+
+ if (opts.author_id) {
+ message.author = await User.getPublicUser(opts.author_id);
+ const rights = await getRights(opts.author_id);
+ rights.hasThrow("SEND_MESSAGES");
+ }
+ if (opts.application_id) {
+ message.application = await Application.findOneOrFail({
+ where: { id: opts.application_id },
+ });
+ }
+
+ let permission: undefined | Permissions;
+ if (opts.webhook_id) {
+ message.webhook = await Webhook.findOneOrFail({
+ where: { id: opts.webhook_id },
+ });
+
+ message.author =
+ (await User.findOne({
+ where: { id: opts.webhook_id },
+ })) || undefined;
+
+ if (!message.author) {
+ message.author = User.create({
+ id: opts.webhook_id,
+ username: message.webhook.name,
+ discriminator: "0000",
+ avatar: message.webhook.avatar,
+ public_flags: 0,
+ premium: false,
+ premium_type: 0,
+ bot: true,
+ created_at: new Date(),
+ verified: true,
+ rights: "0",
+ data: {
+ valid_tokens_since: new Date(),
+ },
+ });
+
+ await message.author.save();
+ }
+
+ if (opts.username) {
+ message.username = opts.username;
+ message.author.username = message.username;
+ }
+ if (opts.avatar_url) {
+ const avatarData = await fetch(opts.avatar_url);
+ const base64 = await avatarData
+ .buffer()
+ .then((x) => x.toString("base64"));
+
+ const dataUri =
+ "data:" +
+ avatarData.headers.get("content-type") +
+ ";base64," +
+ base64;
+
+ message.avatar = await handleFile(
+ `/avatars/${opts.webhook_id}`,
+ dataUri as string,
+ );
+ message.author.avatar = message.avatar;
+ }
+ } else {
+ permission = await getPermission(
+ opts.author_id,
+ channel.guild_id,
+ opts.channel_id,
+ );
+ permission.hasThrow("SEND_MESSAGES");
+ if (permission.cache.member) {
+ message.member = permission.cache.member;
+ }
+
+ if (opts.tts) permission.hasThrow("SEND_TTS_MESSAGES");
+ if (opts.message_reference) {
+ permission.hasThrow("READ_MESSAGE_HISTORY");
+ // code below has to be redone when we add custom message routing
+ if (message.guild_id !== null) {
+ const guild = await Guild.findOneOrFail({
+ where: { id: channel.guild_id },
+ });
+ if (!opts.message_reference.guild_id)
+ opts.message_reference.guild_id = channel.guild_id;
+ if (!opts.message_reference.channel_id)
+ opts.message_reference.channel_id = opts.channel_id;
+
+ if (!guild.features.includes("CROSS_CHANNEL_REPLIES")) {
+ if (opts.message_reference.guild_id !== channel.guild_id)
+ throw new HTTPError(
+ "You can only reference messages from this guild",
+ );
+ if (opts.message_reference.channel_id !== opts.channel_id)
+ throw new HTTPError(
+ "You can only reference messages from this channel",
+ );
+ }
+
+ message.message_reference = opts.message_reference;
+ }
+ /** Q: should be checked if the referenced message exists? ANSWER: NO
+ otherwise backfilling won't work **/
+ message.type = MessageType.REPLY;
+ }
+ }
+
+ // TODO: stickers/activity
+ if (
+ !allow_empty &&
+ !opts.content &&
+ !opts.embeds?.length &&
+ !opts.attachments?.length &&
+ !opts.sticker_ids?.length &&
+ !opts.poll &&
+ !opts.components?.length
+ ) {
+ throw new HTTPError("Empty messages are not allowed", 50006);
+ }
+
+ let content = opts.content;
+
+ // root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients.
+ //const mention_channel_ids = [] as string[];
+ const mention_role_ids = [] as string[];
+ const mention_user_ids = [] as string[];
+ let mention_everyone = false;
+
+ if (content) {
+ // TODO: explicit-only mentions
+ message.content = content.trim();
+ content = content.replace(/ *`[^)]*` */g, ""); // remove codeblocks
+ // root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients.
+ /*for (const [, mention] of content.matchAll(CHANNEL_MENTION)) {
+ if (!mention_channel_ids.includes(mention))
+ mention_channel_ids.push(mention);
+ }*/
+
+ for (const [, mention] of content.matchAll(USER_MENTION)) {
+ if (!mention_user_ids.includes(mention))
+ mention_user_ids.push(mention);
+ }
+
+ await Promise.all(
+ Array.from(content.matchAll(ROLE_MENTION)).map(
+ async ([, mention]) => {
+ const role = await Role.findOneOrFail({
+ where: { id: mention, guild_id: channel.guild_id },
+ });
+ if (
+ role.mentionable ||
+ opts.webhook_id ||
+ permission?.has("MANAGE_ROLES")
+ ) {
+ mention_role_ids.push(mention);
+ }
+ },
+ ),
+ );
+
+ if (opts.webhook_id || permission?.has("MENTION_EVERYONE")) {
+ mention_everyone =
+ !!content.match(EVERYONE_MENTION) ||
+ !!content.match(HERE_MENTION);
+ }
+ }
+
+ // root@Rory - 20/02/2023 - This breaks channel mentions in test client. We're not sure this was used in older clients.
+ /*message.mention_channels = mention_channel_ids.map((x) =>
+ Channel.create({ id: x }),
+ );*/
+ message.mention_roles = mention_role_ids.map((x) => Role.create({ id: x }));
+ message.mentions = mention_user_ids.map((x) => User.create({ id: x }));
+ message.mention_everyone = mention_everyone;
+
+ // TODO: check and put it all in the body
+
+ return message;
+}
+
+// TODO: cache link result in db
+export async function postHandleMessage(message: Message) {
+ const content = message.content?.replace(/ *`[^)]*` */g, ""); // remove markdown
+ let links = content?.match(LINK_REGEX);
+ if (!links) return;
+
+ const data = { ...message };
+ data.embeds = data.embeds.filter((x) => x.type !== "link");
+
+ links = links.slice(0, 20) as RegExpMatchArray; // embed max 20 links — TODO: make this configurable with instance policies
+
+ const cachePromises = [];
+
+ for (const link of links) {
+ // Don't embed links in <>
+ if (link.startsWith("<") && link.endsWith(">")) continue;
+
+ const url = new URL(link);
+
+ const cached = await EmbedCache.findOne({ where: { url: link } });
+ if (cached) {
+ data.embeds.push(cached.embed);
+ continue;
+ }
+
+ // bit gross, but whatever!
+ const endpointPublic =
+ Config.get().cdn.endpointPublic || "http://127.0.0.1"; // lol
+ const handler =
+ url.hostname == new URL(endpointPublic).hostname
+ ? EmbedHandlers["self"]
+ : EmbedHandlers[url.hostname] || EmbedHandlers["default"];
+
+ try {
+ let res = await handler(url);
+ if (!res) continue;
+ // tried to use shorthand but types didn't like me L
+ if (!Array.isArray(res)) res = [res];
+
+ for (const embed of res) {
+ const cache = EmbedCache.create({
+ url: link,
+ embed: embed,
+ });
+ cachePromises.push(cache.save());
+ data.embeds.push(embed);
+ }
+ } catch (e) {
+ console.error(`[Embeds] Error while generating embed`, e);
+ Sentry.captureException(e, (scope) => {
+ scope.clear();
+ scope.setContext("request", { url });
+ return scope;
+ });
+ continue;
+ }
+ }
+
+ if (!data.embeds) return;
+
+ await Promise.all([
+ emitEvent({
+ event: "MESSAGE_UPDATE",
+ channel_id: message.channel_id,
+ data,
+ } as MessageUpdateEvent),
+ Message.update(
+ { id: message.id, channel_id: message.channel_id },
+ { embeds: data.embeds },
+ ),
+ ...cachePromises,
+ ]);
+}
+
+export async function sendMessage(opts: MessageOptions) {
+ const message = await handleMessage({ ...opts, timestamp: new Date() });
+
+ await Promise.all([
+ Message.insert(message),
+ emitEvent({
+ event: "MESSAGE_CREATE",
+ channel_id: opts.channel_id,
+ data: message.toJSON(),
+ } as MessageCreateEvent),
+ ]);
+
+ // no await as it should catch error non-blockingly
+ postHandleMessage(message).catch((e) =>
+ console.error("[Message] post-message handler failed", e),
+ );
+
+ return message;
+}
+
+interface MessageOptions extends MessageCreateSchema {
+ id?: string;
+ type?: MessageType;
+ pinned?: boolean;
+ author_id?: string;
+ webhook_id?: string;
+ application_id?: string;
+ embeds?: Embed[];
+ channel_id?: string;
+ attachments?: Attachment[];
+ edited_timestamp?: Date;
+ timestamp?: Date;
+ username?: string;
+ avatar_url?: string;
+}
diff --git a/src/admin-api/util/handlers/Voice.ts b/src/admin-api/util/handlers/Voice.ts
new file mode 100644
index 000000000..db06bd33c
--- /dev/null
+++ b/src/admin-api/util/handlers/Voice.ts
@@ -0,0 +1,55 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { Config } from "@spacebar/util";
+import { distanceBetweenLocations, IPAnalysis } from "../utility/ipAddress";
+
+export async function getVoiceRegions(ipAddress: string, vip: boolean) {
+ const regions = Config.get().regions;
+ const availableRegions = regions.available.filter((ar) =>
+ vip ? true : !ar.vip,
+ );
+ let optimalId = regions.default;
+
+ if (!regions.useDefaultAsOptimal) {
+ const clientIpAnalysis = await IPAnalysis(ipAddress);
+
+ let min = Number.POSITIVE_INFINITY;
+
+ for (const ar of availableRegions) {
+ //TODO the endpoint location should be saved in the database if not already present to prevent IPAnalysis call
+ const dist = distanceBetweenLocations(
+ clientIpAnalysis,
+ ar.location || (await IPAnalysis(ar.endpoint)),
+ );
+
+ if (dist < min) {
+ min = dist;
+ optimalId = ar.id;
+ }
+ }
+ }
+
+ return availableRegions.map((ar) => ({
+ id: ar.id,
+ name: ar.name,
+ custom: ar.custom,
+ deprecated: ar.deprecated,
+ optimal: ar.id === optimalId,
+ }));
+}
diff --git a/src/admin-api/util/handlers/route.ts b/src/admin-api/util/handlers/route.ts
new file mode 100644
index 000000000..2c98783a4
--- /dev/null
+++ b/src/admin-api/util/handlers/route.ts
@@ -0,0 +1,142 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import {
+ DiscordApiErrors,
+ EVENT,
+ FieldErrors,
+ PermissionResolvable,
+ Permissions,
+ RightResolvable,
+ Rights,
+ SpacebarApiErrors,
+ ajv,
+ getPermission,
+ getRights,
+ normalizeBody,
+} from "@spacebar/util";
+import { AnyValidateFunction } from "ajv/dist/core";
+import { NextFunction, Request, Response } from "express";
+
+declare global {
+ // TODO: fix this
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Express {
+ interface Request {
+ permission?: Permissions;
+ }
+ }
+}
+
+export type RouteResponse = {
+ status?: number;
+ body?: `${string}Response`;
+ headers?: Record;
+};
+
+export interface RouteOptions {
+ permission?: PermissionResolvable;
+ right?: RightResolvable;
+ requestBody?: `${string}Schema`; // typescript interface name
+ responses?: {
+ [status: number]: {
+ // body?: `${string}Response`;
+ body?: string;
+ };
+ };
+ event?: EVENT | EVENT[];
+ summary?: string;
+ description?: string;
+ query?: {
+ [key: string]: {
+ type: string;
+ required?: boolean;
+ description?: string;
+ values?: string[];
+ };
+ };
+ deprecated?: boolean;
+ // test?: {
+ // response?: RouteResponse;
+ // body?: unknown;
+ // path?: string;
+ // event?: EVENT | EVENT[];
+ // headers?: Record;
+ // };
+}
+
+export function route(opts: RouteOptions) {
+ let validate: AnyValidateFunction | undefined;
+ if (opts.requestBody) {
+ validate = ajv.getSchema(opts.requestBody);
+ if (!validate)
+ throw new Error(`Body schema ${opts.requestBody} not found`);
+ }
+
+ return async (req: Request, res: Response, next: NextFunction) => {
+ if (opts.permission) {
+ req.permission = await getPermission(
+ req.user_id,
+ req.params.guild_id,
+ req.params.channel_id,
+ );
+
+ const requiredPerms = Array.isArray(opts.permission)
+ ? opts.permission
+ : [opts.permission];
+ requiredPerms.forEach((perm) => {
+ // bitfield comparison: check if user lacks certain permission
+ if (!req.permission!.has(new Permissions(perm))) {
+ throw DiscordApiErrors.MISSING_PERMISSIONS.withParams(
+ perm as string,
+ );
+ }
+ });
+ }
+
+ if (opts.right) {
+ const required = new Rights(opts.right);
+ req.rights = await getRights(req.user_id);
+
+ if (!req.rights || !req.rights.has(required)) {
+ throw SpacebarApiErrors.MISSING_RIGHTS.withParams(
+ opts.right as string,
+ );
+ }
+ }
+
+ if (validate) {
+ const valid = validate(normalizeBody(req.body));
+ if (!valid) {
+ const fields: Record<
+ string,
+ { code?: string; message: string }
+ > = {};
+ validate.errors?.forEach(
+ (x) =>
+ (fields[x.instancePath.slice(1)] = {
+ code: x.keyword,
+ message: x.message || "",
+ }),
+ );
+ throw FieldErrors(fields);
+ }
+ }
+ next();
+ };
+}
diff --git a/src/admin-api/util/index.ts b/src/admin-api/util/index.ts
new file mode 100644
index 000000000..cb26d4f54
--- /dev/null
+++ b/src/admin-api/util/index.ts
@@ -0,0 +1,28 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+export * from "./utility/Base64";
+export * from "./utility/ipAddress";
+export * from "./handlers/Message";
+export * from "./utility/passwordStrength";
+export * from "./utility/RandomInviteID";
+export * from "./handlers/route";
+export * from "./utility/String";
+export * from "./handlers/Voice";
+export * from "./utility/captcha";
+export * from "./utility/EmbedHandlers";
diff --git a/src/admin-api/util/utility/Base64.ts b/src/admin-api/util/utility/Base64.ts
new file mode 100644
index 000000000..c6d1257ce
--- /dev/null
+++ b/src/admin-api/util/utility/Base64.ts
@@ -0,0 +1,66 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+const alphabet =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+";
+
+// binary to string lookup table
+const b2s = alphabet.split("");
+
+// string to binary lookup table
+// 123 == 'z'.charCodeAt(0) + 1
+const s2b = new Array(123);
+for (let i = 0; i < alphabet.length; i++) {
+ s2b[alphabet.charCodeAt(i)] = i;
+}
+
+// number to base64
+export const ntob = (n: number): string => {
+ if (n < 0) return `-${ntob(-n)}`;
+
+ let lo = n >>> 0;
+ let hi = (n / 4294967296) >>> 0;
+
+ let right = "";
+ while (hi > 0) {
+ right = b2s[0x3f & lo] + right;
+ lo >>>= 6;
+ lo |= (0x3f & hi) << 26;
+ hi >>>= 6;
+ }
+
+ let left = "";
+ do {
+ left = b2s[0x3f & lo] + left;
+ lo >>>= 6;
+ } while (lo > 0);
+
+ return left + right;
+};
+
+// base64 to number
+export const bton = (base64: string) => {
+ let number = 0;
+ const sign = base64.charAt(0) === "-" ? 1 : 0;
+
+ for (let i = sign; i < base64.length; i++) {
+ number = number * 64 + s2b[base64.charCodeAt(i)];
+ }
+
+ return sign ? -number : number;
+};
diff --git a/src/admin-api/util/utility/EmbedHandlers.ts b/src/admin-api/util/utility/EmbedHandlers.ts
new file mode 100644
index 000000000..450d9d7ed
--- /dev/null
+++ b/src/admin-api/util/utility/EmbedHandlers.ts
@@ -0,0 +1,576 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { Config, Embed, EmbedImage, EmbedType } from "@spacebar/util";
+import * as cheerio from "cheerio";
+import crypto from "crypto";
+import fetch, { RequestInit } from "node-fetch-commonjs";
+import { yellow } from "picocolors";
+import probe from "probe-image-size";
+
+export const DEFAULT_FETCH_OPTIONS: RequestInit = {
+ redirect: "follow",
+ follow: 1,
+ headers: {
+ "user-agent":
+ "Mozilla/5.0 (compatible; Spacebar/1.0; +https://github.com/spacebarchat/server)",
+ },
+ // size: 1024 * 1024 * 5, // grabbed from config later
+ compress: true,
+ method: "GET",
+};
+
+const makeEmbedImage = (
+ url: string | undefined,
+ width: number | undefined,
+ height: number | undefined,
+): Required | undefined => {
+ if (!url || !width || !height) return undefined;
+ return {
+ url,
+ width,
+ height,
+ proxy_url: getProxyUrl(new URL(url), width, height),
+ };
+};
+
+let hasWarnedAboutImagor = false;
+
+export const getProxyUrl = (
+ url: URL,
+ width: number,
+ height: number,
+): string => {
+ const { resizeWidthMax, resizeHeightMax, imagorServerUrl } =
+ Config.get().cdn;
+ const secret = Config.get().security.requestSignature;
+ width = Math.min(width || 500, resizeWidthMax || width);
+ height = Math.min(height || 500, resizeHeightMax || width);
+
+ // Imagor
+ if (imagorServerUrl) {
+ const path = `${width}x${height}/${url.host}${url.pathname}`;
+
+ const hash = crypto
+ .createHmac("sha1", secret)
+ .update(path)
+ .digest("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+
+ return `${imagorServerUrl}/${hash}/${path}`;
+ }
+
+ if (!hasWarnedAboutImagor) {
+ hasWarnedAboutImagor = true;
+ console.log(
+ "[Embeds]",
+ yellow(
+ "Imagor has not been set up correctly. https://docs.spacebar.chat/setup/server/configuration/imagor/",
+ ),
+ );
+ }
+
+ return url.toString();
+};
+
+const getMeta = ($: cheerio.CheerioAPI, name: string): string | undefined => {
+ let elem = $(`meta[property="${name}"]`);
+ if (!elem.length) elem = $(`meta[name="${name}"]`);
+ const ret = elem.attr("content") || elem.text();
+ return ret.trim().length == 0 ? undefined : ret;
+};
+
+const tryParseInt = (str: string | undefined) => {
+ if (!str) return undefined;
+ try {
+ return parseInt(str);
+ } catch (e) {
+ return undefined;
+ }
+};
+
+export const getMetaDescriptions = (text: string) => {
+ const $ = cheerio.load(text);
+
+ return {
+ type: getMeta($, "og:type"),
+ title: getMeta($, "og:title") || $("title").first().text(),
+ provider_name: getMeta($, "og:site_name"),
+ author: getMeta($, "article:author"),
+ description: getMeta($, "og:description") || getMeta($, "description"),
+ image: getMeta($, "og:image") || getMeta($, "twitter:image"),
+ image_fallback: $(`image`).attr("src"),
+ video_fallback: $(`video`).attr("src"),
+ width: tryParseInt(getMeta($, "og:image:width")),
+ height: tryParseInt(getMeta($, "og:image:height")),
+ url: getMeta($, "og:url"),
+ youtube_embed: getMeta($, "og:video:secure_url"),
+ site_name: getMeta($, "og:site_name"),
+
+ $,
+ };
+};
+
+const doFetch = async (url: URL) => {
+ try {
+ return await fetch(url, {
+ ...DEFAULT_FETCH_OPTIONS,
+ size: Config.get().limits.message.maxEmbedDownloadSize,
+ });
+ } catch (e) {
+ return null;
+ }
+};
+
+const genericImageHandler = async (url: URL): Promise