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 => { + const type = await fetch(url, { + ...DEFAULT_FETCH_OPTIONS, + method: "HEAD", + }); + + let image; + + if (type.headers.get("content-type")?.indexOf("image") !== -1) { + const result = await probe(url.href); + image = makeEmbedImage(url.href, result.width, result.height); + } else if (type.headers.get("content-type")?.indexOf("video") !== -1) { + // TODO + return null; + } else { + // have to download the page, unfortunately + const response = await doFetch(url); + if (!response) return null; + const metas = getMetaDescriptions(await response.text()); + image = makeEmbedImage( + metas.image || metas.image_fallback, + metas.width, + metas.height, + ); + } + + if (!image) return null; + + return { + url: url.href, + type: EmbedType.image, + thumbnail: image, + }; +}; + +export const EmbedHandlers: { + [key: string]: (url: URL) => Promise; +} = { + // the url does not have a special handler + default: async (url: URL) => { + const type = await fetch(url, { + ...DEFAULT_FETCH_OPTIONS, + method: "HEAD", + }); + if (type.headers.get("content-type")?.indexOf("image") !== -1) + return await genericImageHandler(url); + + const response = await doFetch(url); + if (!response) return null; + + const text = await response.text(); + const metas = getMetaDescriptions(text); + + // TODO: handle video + + if (!metas.image) metas.image = metas.image_fallback; + + if (metas.image && (!metas.width || !metas.height)) { + const result = await probe(metas.image); + metas.width = result.width; + metas.height = result.height; + } + + if (!metas.image && (!metas.title || !metas.description)) { + // we don't have any content to display + return null; + } + + let embedType = EmbedType.link; + if (metas.type == "article") embedType = EmbedType.article; + if (metas.type == "object") embedType = EmbedType.article; // github + if (metas.type == "rich") embedType = EmbedType.rich; + + return { + url: url.href, + type: embedType, + title: metas.title, + thumbnail: makeEmbedImage(metas.image, metas.width, metas.height), + description: metas.description, + provider: metas.site_name + ? { + name: metas.site_name, + url: url.origin, + } + : undefined, + }; + }, + + "giphy.com": genericImageHandler, + "media4.giphy.com": genericImageHandler, + "tenor.com": genericImageHandler, + "c.tenor.com": genericImageHandler, + "media.tenor.com": genericImageHandler, + + "facebook.com": (url) => EmbedHandlers["www.facebook.com"](url), + "www.facebook.com": async (url: URL) => { + const response = await doFetch(url); + if (!response) return null; + const metas = getMetaDescriptions(await response.text()); + + return { + url: url.href, + type: EmbedType.link, + title: metas.title, + description: metas.description, + thumbnail: makeEmbedImage(metas.image, 640, 640), + color: 16777215, + }; + }, + + "twitter.com": (url) => EmbedHandlers["www.twitter.com"](url), + "www.twitter.com": async (url: URL) => { + const token = Config.get().external.twitter; + if (!token) return null; + + if (!url.href.includes("/status/")) return null; // TODO; + const id = url.pathname.split("/")[3]; // super bad lol + if (!parseInt(id)) return null; + const endpointUrl = + `https://api.twitter.com/2/tweets/${id}` + + `?expansions=author_id,attachments.media_keys` + + `&media.fields=url,width,height` + + `&tweet.fields=created_at,public_metrics` + + `&user.fields=profile_image_url`; + + const response = await fetch(endpointUrl, { + ...DEFAULT_FETCH_OPTIONS, + headers: { + authorization: `Bearer ${token}`, + }, + }); + const json = (await response.json()) as { + errors?: never[]; + includes: { + users: { + profile_image_url: string; + username: string; + name: string; + }[]; + media: { + type: string; + width: number; + height: number; + url: string; + }[]; + }; + data: { + text: string; + created_at: string; + public_metrics: { like_count: number; retweet_count: number }; + }; + }; + if (json.errors) return null; + const author = json.includes.users[0]; + const text = json.data.text; + const created_at = new Date(json.data.created_at); + const metrics = json.data.public_metrics; + const media = json.includes.media?.filter( + (x: { type: string }) => x.type == "photo", + ); + + const embed: Embed = { + type: EmbedType.rich, + url: `${url.origin}${url.pathname}`, + description: text, + author: { + url: `https://twitter.com/${author.username}`, + name: `${author.name} (@${author.username})`, + proxy_icon_url: getProxyUrl( + new URL(author.profile_image_url), + 400, + 400, + ), + icon_url: author.profile_image_url, + }, + timestamp: created_at, + fields: [ + { + inline: true, + name: "Likes", + value: metrics.like_count.toString(), + }, + { + inline: true, + name: "Retweet", + value: metrics.retweet_count.toString(), + }, + ], + color: 1942002, + footer: { + text: "Twitter", + proxy_icon_url: getProxyUrl( + new URL( + "https://abs.twimg.com/icons/apple-touch-icon-192x192.png", + ), + 192, + 192, + ), + icon_url: + "https://abs.twimg.com/icons/apple-touch-icon-192x192.png", + }, + // Discord doesn't send this? + // provider: { + // name: "Twitter", + // url: "https://twitter.com" + // }, + }; + + if (media && media.length > 0) { + embed.image = { + width: media[0].width, + height: media[0].height, + url: media[0].url, + proxy_url: getProxyUrl( + new URL(media[0].url), + media[0].width, + media[0].height, + ), + }; + media.shift(); + } + + return embed; + + // TODO: Client won't merge these into a single embed, for some reason. + // return [embed, ...media.map((x: any) => ({ + // // generate new embeds for each additional attachment + // type: EmbedType.rich, + // url: url.href, + // image: { + // width: x.width, + // height: x.height, + // url: x.url, + // proxy_url: getProxyUrl(new URL(x.url), x.width, x.height) + // } + // }))]; + }, + + "open.spotify.com": async (url: URL) => { + const response = await doFetch(url); + if (!response) return null; + const metas = getMetaDescriptions(await response.text()); + + return { + url: url.href, + type: EmbedType.link, + title: metas.title, + description: metas.description, + thumbnail: makeEmbedImage(metas.image, 640, 640), + provider: { + url: "https://spotify.com", + name: "Spotify", + }, + }; + }, + + // TODO: docs: Pixiv won't work without Imagor + "pixiv.net": (url) => EmbedHandlers["www.pixiv.net"](url), + "www.pixiv.net": async (url: URL) => { + const response = await doFetch(url); + if (!response) return null; + const metas = getMetaDescriptions(await response.text()); + + if (!metas.image) return null; + + return { + url: url.href, + type: EmbedType.image, + title: metas.title, + description: metas.description, + image: makeEmbedImage( + metas.image || metas.image_fallback, + metas.width, + metas.height, + ), + provider: { + url: "https://pixiv.net", + name: "Pixiv", + }, + }; + }, + + "store.steampowered.com": async (url: URL) => { + const response = await doFetch(url); + if (!response) return null; + const metas = getMetaDescriptions(await response.text()); + const numReviews = metas.$("#review_summary_num_reviews").val() as + | string + | undefined; + const price = metas + .$(".game_purchase_price.price") + .data("price-final") as number | undefined; + const releaseDate = metas + .$(".release_date") + .find("div.date") + .text() + .trim(); + const isReleased = new Date(releaseDate) < new Date(); + + const fields: Embed["fields"] = []; + + if (numReviews) + fields.push({ + name: "Reviews", + value: numReviews, + inline: true, + }); + + if (price) + fields.push({ + name: "Price", + value: `$${price / 100}`, + inline: true, + }); + + // if the release date is in the past, it's already out + if (releaseDate && !isReleased) + fields.push({ + name: "Release Date", + value: releaseDate, + inline: true, + }); + + return { + url: url.href, + type: EmbedType.rich, + title: metas.title, + description: metas.description, + image: { + // TODO: meant to be thumbnail. + // isn't this standard across all of steam? + width: 460, + height: 215, + url: metas.image, + proxy_url: metas.image + ? getProxyUrl(new URL(metas.image), 460, 215) + : undefined, + }, + provider: { + url: "https://store.steampowered.com", + name: "Steam", + }, + fields, + // TODO: Video + }; + }, + + "reddit.com": (url) => EmbedHandlers["www.reddit.com"](url), + "www.reddit.com": async (url: URL) => { + const res = await EmbedHandlers["default"](url); + return { + ...res, + color: 16777215, + provider: { + name: "reddit", + }, + }; + }, + + "youtu.be": (url) => EmbedHandlers["www.youtube.com"](url), + "youtube.com": (url) => EmbedHandlers["www.youtube.com"](url), + "www.youtube.com": async (url: URL): Promise => { + const response = await doFetch(url); + if (!response) return null; + const metas = getMetaDescriptions(await response.text()); + + return { + video: makeEmbedImage( + metas.youtube_embed, + metas.width, + metas.height, + ), + url: url.href, + type: metas.youtube_embed ? EmbedType.video : EmbedType.link, + title: metas.title, + thumbnail: makeEmbedImage( + metas.image || metas.image_fallback, + metas.width, + metas.height, + ), + provider: { + url: "https://www.youtube.com", + name: "YouTube", + }, + description: metas.description, + color: 16711680, + author: metas.author + ? { + name: metas.author, + // TODO: author channel url + } + : undefined, + }; + }, + + "www.xkcd.com": (url) => EmbedHandlers["xkcd.com"](url), + "xkcd.com": async (url) => { + const response = await doFetch(url); + if (!response) return null; + + const metas = getMetaDescriptions(await response.text()); + const hoverText = metas.$("#comic img").attr("title"); + + if (!metas.image) return null; + + const { width, height } = await probe(metas.image); + + return { + url: url.href, + type: EmbedType.rich, + title: `xkcd: ${metas.title}`, + image: makeEmbedImage(metas.image, width, height), + footer: hoverText + ? { + text: hoverText, + } + : undefined, + }; + }, + + // the url is an image from this instance + self: async (url: URL): Promise => { + const result = await probe(url.href); + + return { + url: url.href, + type: EmbedType.image, + thumbnail: { + width: result.width, + height: result.height, + url: url.href, + proxy_url: url.href, + }, + }; + }, +}; diff --git a/src/admin-api/util/utility/RandomInviteID.ts b/src/admin-api/util/utility/RandomInviteID.ts new file mode 100644 index 000000000..926750d35 --- /dev/null +++ b/src/admin-api/util/utility/RandomInviteID.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 { Snowflake } from "@spacebar/util"; +import crypto from "crypto"; + +// TODO: 'random'? seriously? who named this? +// And why is this even here? Just use cryto.randomBytes? + +export function random(length = 6) { + // Declare all characters + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + // Pick characers randomly + let str = ""; + for (let i = 0; i < length; i++) { + str += chars.charAt(Math.floor(crypto.randomInt(chars.length))); + } + + return str; +} + +export function snowflakeBasedInvite() { + // Declare all characters + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const base = BigInt(chars.length); + let snowflake = Snowflake.generateWorkerProcess(); + + // snowflakes hold ~10.75 characters worth of entropy; + // safe to generate a 8-char invite out of them + const str = ""; + for (let i = 0; i < 10; i++) { + str.concat(chars.charAt(Number(snowflake % base))); + snowflake = snowflake / base; + } + + return str.substr(3, 8).split("").reverse().join(""); +} diff --git a/src/admin-api/util/utility/String.ts b/src/admin-api/util/utility/String.ts new file mode 100644 index 000000000..eef69e39b --- /dev/null +++ b/src/admin-api/util/utility/String.ts @@ -0,0 +1,44 @@ +/* + 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 { Request } from "express"; +import { ntob } from "./Base64"; +import { FieldErrors } from "@spacebar/util"; + +export function checkLength( + str: string, + min: number, + max: number, + key: string, + req: Request, +) { + if (str.length < min || str.length > max) { + throw FieldErrors({ + [key]: { + code: "BASE_TYPE_BAD_LENGTH", + message: req.t("common:field.BASE_TYPE_BAD_LENGTH", { + length: `${min} - ${max}`, + }), + }, + }); + } +} + +export function generateCode() { + return ntob(Date.now() + Math.randomIntBetween(0, 10000)); +} diff --git a/src/admin-api/util/utility/captcha.ts b/src/admin-api/util/utility/captcha.ts new file mode 100644 index 000000000..3c0ac7669 --- /dev/null +++ b/src/admin-api/util/utility/captcha.ts @@ -0,0 +1,68 @@ +/* + 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 fetch from "node-fetch-commonjs"; + +export interface hcaptchaResponse { + success: boolean; + challenge_ts: string; + hostname: string; + credit: boolean; + "error-codes": string[]; + score: number; // enterprise only + score_reason: string[]; // enterprise only +} + +export interface recaptchaResponse { + success: boolean; + score: number; // between 0 - 1 + action: string; + challenge_ts: string; + hostname: string; + "error-codes"?: string[]; +} + +const verifyEndpoints = { + hcaptcha: "https://hcaptcha.com/siteverify", + recaptcha: "https://www.google.com/recaptcha/api/siteverify", +}; + +export async function verifyCaptcha(response: string, ip?: string) { + const { security } = Config.get(); + const { service, secret, sitekey } = security.captcha; + + if (!service || !secret || !sitekey) + throw new Error( + "CAPTCHA is not configured correctly. https://docs.spacebar.chat/setup/server/security/captcha/", + ); + + const res = await fetch(verifyEndpoints[service], { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: + `response=${encodeURIComponent(response)}` + + `&secret=${encodeURIComponent(secret)}` + + `&sitekey=${encodeURIComponent(sitekey)}` + + (ip ? `&remoteip=${encodeURIComponent(ip)}` : ""), + }); + + return (await res.json()) as hcaptchaResponse | recaptchaResponse; +} diff --git a/src/admin-api/util/utility/ipAddress.ts b/src/admin-api/util/utility/ipAddress.ts new file mode 100644 index 000000000..c19c7c352 --- /dev/null +++ b/src/admin-api/util/utility/ipAddress.ts @@ -0,0 +1,138 @@ +/* + 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 { Request } from "express"; +// use ipdata package instead of simple fetch because of integrated caching +import fetch from "node-fetch-commonjs"; + +const exampleData = { + ip: "", + is_eu: true, + city: "", + region: "", + region_code: "", + country_name: "", + country_code: "", + continent_name: "", + continent_code: "", + latitude: 0, + longitude: 0, + postal: "", + calling_code: "", + flag: "", + emoji_flag: "", + emoji_unicode: "", + asn: { + asn: "", + name: "", + domain: "", + route: "", + type: "isp", + }, + languages: [ + { + name: "", + native: "", + }, + ], + currency: { + name: "", + code: "", + symbol: "", + native: "", + plural: "", + }, + time_zone: { + name: "", + abbr: "", + offset: "", + is_dst: true, + current_time: "", + }, + threat: { + is_tor: false, + is_proxy: false, + is_anonymous: false, + is_known_attacker: false, + is_known_abuser: false, + is_threat: false, + is_bogon: false, + }, + count: 0, + status: 200, +}; + +//TODO add function that support both ip and domain names +export async function IPAnalysis(ip: string): Promise { + const { ipdataApiKey } = Config.get().security; + if (!ipdataApiKey) return { ...exampleData, ip }; + + return ( + await fetch(`https://api.ipdata.co/${ip}?api-key=${ipdataApiKey}`) + ).json() as Promise; +} + +export function isProxy(data: typeof exampleData) { + if (!data || !data.asn || !data.threat) return false; + if (data.asn.type !== "isp") return true; + if (Object.values(data.threat).some((x) => x)) return true; + + return false; +} + +export function getIpAdress(req: Request): string { + // TODO: express can do this (trustProxies: true)? + + return ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + req.headers[Config.get().security.forwardedFor] || + req.socket.remoteAddress + ); +} + +type Location = { latitude: number; longitude: number }; +export function distanceBetweenLocations( + loc1: Location, + loc2: Location, +): number { + return distanceBetweenCoords( + loc1.latitude, + loc1.longitude, + loc2.latitude, + loc2.longitude, + ); +} + +//Haversine function +function distanceBetweenCoords( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +) { + const p = 0.017453292519943295; // Math.PI / 180 + const c = Math.cos; + const a = + 0.5 - + c((lat2 - lat1) * p) / 2 + + (c(lat1 * p) * c(lat2 * p) * (1 - c((lon2 - lon1) * p))) / 2; + + return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km +} diff --git a/src/admin-api/util/utility/passwordStrength.ts b/src/admin-api/util/utility/passwordStrength.ts new file mode 100644 index 000000000..fd627fbf7 --- /dev/null +++ b/src/admin-api/util/utility/passwordStrength.ts @@ -0,0 +1,84 @@ +/* + 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 "missing-native-js-functions"; + +const reNUMBER = /[0-9]/g; +const reUPPERCASELETTER = /[A-Z]/g; +const reSYMBOLS = /[A-Z,a-z,0-9]/g; + +// const blocklist: string[] = []; // TODO: update ones passwordblocklist is stored in db +/* + * https://en.wikipedia.org/wiki/Password_policy + * password must meet following criteria, to be perfect: + * - min chars + * - min numbers + * - min symbols + * - min uppercase chars + * - shannon entropy folded into [0, 1) interval + * + * Returns: 0 > pw > 1 + */ +export function checkPassword(password: string): number { + const { minLength, minNumbers, minUpperCase, minSymbols } = + Config.get().register.password; + let strength = 0; + + // checks for total password len + if (password.length >= minLength - 1) { + strength += 0.05; + } + + // checks for amount of Numbers + if (password.count(reNUMBER) >= minNumbers - 1) { + strength += 0.05; + } + + // checks for amount of Uppercase Letters + if (password.count(reUPPERCASELETTER) >= minUpperCase - 1) { + strength += 0.05; + } + + // checks for amount of symbols + if (password.replace(reSYMBOLS, "").length >= minSymbols - 1) { + strength += 0.05; + } + + // checks if password only consists of numbers or only consists of chars + if ( + password.length == password.count(reNUMBER) || + password.length === password.count(reUPPERCASELETTER) + ) { + strength = 0; + } + + const entropyMap: { [key: string]: number } = {}; + for (let i = 0; i < password.length; i++) { + if (entropyMap[password[i]]) entropyMap[password[i]]++; + else entropyMap[password[i]] = 1; + } + + const entropies = Object.values(entropyMap); + + entropies.map((x) => x / entropyMap.length); + strength += + entropies.reduceRight((a: number, x: number) => a - x * Math.log2(x)) / + Math.log2(password.length); + return strength; +} diff --git a/src/bundle/Server.ts b/src/bundle/Server.ts index d281120d1..cadc42408 100644 --- a/src/bundle/Server.ts +++ b/src/bundle/Server.ts @@ -20,6 +20,7 @@ process.on("unhandledRejection", console.error); process.on("uncaughtException", console.error); import http from "http"; +import * as AdminApi from "@spacebar/admin-api"; import * as Api from "@spacebar/api"; import * as Gateway from "@spacebar/gateway"; import { CDNServer } from "@spacebar/cdn"; @@ -33,6 +34,7 @@ const port = Number(process.env.PORT) || 3001; const production = process.env.NODE_ENV == "development" ? false : true; server.on("request", app); +const adminApi = new AdminApi.AdminApiServer({ server, port, production, app }); const api = new Api.SpacebarServer({ server, port, production, app }); const cdn = new CDNServer({ server, port, production, app }); const gateway = new Gateway.Server({ server, port, production }); @@ -41,6 +43,7 @@ process.on("SIGTERM", async () => { console.log("Shutting down due to SIGTERM"); await gateway.stop(); await cdn.stop(); + await adminApi.stop(); await api.stop(); server.close(); Sentry.close(); @@ -54,7 +57,7 @@ async function main() { await new Promise((resolve) => server.listen({ port }, () => resolve(undefined)), ); - await Promise.all([api.start(), cdn.start(), gateway.start()]); + await Promise.all([adminApi.start(), api.start(), cdn.start(), gateway.start()]); Sentry.errorHandler(app); diff --git a/tsconfig.json b/tsconfig.json index 63b5e96cb..f3ab2bcb1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,7 @@ "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, "baseUrl": "./src/", "paths": { + "@spacebar/admin-api": ["./admin-api"], "@spacebar/api*": ["./api"], "@spacebar/gateway*": ["./gateway"], "@spacebar/cdn*": ["./cdn"],