Added api files
This commit is contained in:
parent
99e27306d0
commit
4c1a9b229b
174 changed files with 140670 additions and 0 deletions
147
src/api/Server.ts
Normal file
147
src/api/Server.ts
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
ConnectionConfig,
|
||||||
|
ConnectionLoader,
|
||||||
|
Email,
|
||||||
|
JSONReplacer,
|
||||||
|
Sentry,
|
||||||
|
WebAuthn,
|
||||||
|
initDatabase,
|
||||||
|
initEvent,
|
||||||
|
registerRoutes,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } 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 { initInstance } from "./util/handlers/Instance";
|
||||||
|
|
||||||
|
const PUBLIC_ASSETS_FOLDER = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"assets",
|
||||||
|
"public",
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ValkyrieServerOptions = ServerOptions;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
server: ValkyrieServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValkyrieServer extends Server {
|
||||||
|
public declare options: ValkyrieServerOptions;
|
||||||
|
|
||||||
|
constructor(opts?: Partial<ValkyrieServerOptions>) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
super({ ...opts, errorHandler: false, jsonBody: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
await initDatabase();
|
||||||
|
await Config.init();
|
||||||
|
await initEvent();
|
||||||
|
await Email.init();
|
||||||
|
await ConnectionConfig.init();
|
||||||
|
await initInstance();
|
||||||
|
await Sentry.init(this.app);
|
||||||
|
WebAuthn.init();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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", "/"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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("/__development", )
|
||||||
|
//app.use("/__internals", )
|
||||||
|
app.use("/api/v6", api);
|
||||||
|
app.use("/api/v7", api);
|
||||||
|
app.use("/api/v8", api);
|
||||||
|
app.use("/api/v9", api);
|
||||||
|
app.use("/api", api); // allow unversioned requests
|
||||||
|
|
||||||
|
app.use("/imageproxy/:hash/:size/:url", ImageProxy);
|
||||||
|
|
||||||
|
app.get("/", (req, res) =>
|
||||||
|
res.sendFile(path.join(PUBLIC_ASSETS_FOLDER, "index.html")),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/verify", (req, res) =>
|
||||||
|
res.sendFile(path.join(PUBLIC_ASSETS_FOLDER, "verify.html")),
|
||||||
|
);
|
||||||
|
|
||||||
|
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!`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return super.start();
|
||||||
|
}
|
||||||
|
}
|
8
src/api/global.d.ts
vendored
Normal file
8
src/api/global.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// declare global {
|
||||||
|
// namespace Express {
|
||||||
|
// interface Request {
|
||||||
|
// user_id: any;
|
||||||
|
// token: any;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
3
src/api/index.ts
Normal file
3
src/api/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./Server";
|
||||||
|
export * from "./middlewares/";
|
||||||
|
export * from "./util/";
|
96
src/api/middlewares/Authentication.ts
Normal file
96
src/api/middlewares/Authentication.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import * as Sentry from "@sentry/node";
|
||||||
|
import { checkToken, Rights } from "@valkyrie/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) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return next(new HTTPError(error!.toString(), 400));
|
||||||
|
}
|
||||||
|
}
|
40
src/api/middlewares/BodyParser.ts
Normal file
40
src/api/middlewares/BodyParser.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
22
src/api/middlewares/CORS.ts
Normal file
22
src/api/middlewares/CORS.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
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();
|
||||||
|
}
|
64
src/api/middlewares/ErrorHandler.ts
Normal file
64
src/api/middlewares/ErrorHandler.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { ApiError, FieldError } from "@valkyrie/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" });
|
||||||
|
}
|
||||||
|
}
|
167
src/api/middlewares/ImageProxy.ts
Normal file
167
src/api/middlewares/ImageProxy.ts
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import { Config, JimpType } from "@valkyrie/util";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { yellow } from "picocolors";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
254
src/api/middlewares/RateLimit.ts
Normal file
254
src/api/middlewares/RateLimit.ts
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
import { getIpAdress } from "@valkyrie/api";
|
||||||
|
import { Config, getRights, listenEvent } from "@valkyrie/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 valkyriechat 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<string, RateLimit>();
|
||||||
|
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();
|
||||||
|
*/
|
||||||
|
}
|
36
src/api/middlewares/Translation.ts
Normal file
36
src/api/middlewares/Translation.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
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, {}));
|
||||||
|
}
|
6
src/api/middlewares/index.ts
Normal file
6
src/api/middlewares/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export * from "./Authentication";
|
||||||
|
export * from "./BodyParser";
|
||||||
|
export * from "./CORS";
|
||||||
|
export * from "./ErrorHandler";
|
||||||
|
export * from "./RateLimit";
|
||||||
|
export * from "./ImageProxy";
|
31
src/api/routes/-/healthz.ts
Normal file
31
src/api/routes/-/healthz.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Response, Request } from "express";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { getDatabase } from "@valkyrie/util";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", route({}), (req: Request, res: Response) => {
|
||||||
|
if (!getDatabase()) return res.sendStatus(503);
|
||||||
|
|
||||||
|
return res.sendStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
31
src/api/routes/-/readyz.ts
Normal file
31
src/api/routes/-/readyz.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Response, Request } from "express";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { getDatabase } from "@valkyrie/util";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", route({}), (req: Request, res: Response) => {
|
||||||
|
if (!getDatabase()) return res.sendStatus(503);
|
||||||
|
|
||||||
|
return res.sendStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
141
src/api/routes/applications/#id/bot/index.ts
Normal file
141
src/api/routes/applications/#id/bot/index.ts
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Application,
|
||||||
|
BotModifySchema,
|
||||||
|
DiscordApiErrors,
|
||||||
|
User,
|
||||||
|
createAppBotUser,
|
||||||
|
generateToken,
|
||||||
|
handleFile,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { verifyToken } from "node-2fa";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
204: {
|
||||||
|
body: "TokenOnlyResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const app = await Application.findOneOrFail({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
relations: ["owner"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (app.owner.id != req.user_id)
|
||||||
|
throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
|
||||||
|
|
||||||
|
const user = await createAppBotUser(app, req);
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
token: await generateToken(user.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/reset",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "TokenResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const bot = await User.findOneOrFail({ where: { id: req.params.id } });
|
||||||
|
const owner = await User.findOneOrFail({ where: { id: req.user_id } });
|
||||||
|
|
||||||
|
if (owner.id != req.user_id)
|
||||||
|
throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
|
||||||
|
|
||||||
|
if (
|
||||||
|
owner.totp_secret &&
|
||||||
|
(!req.body.code || verifyToken(owner.totp_secret, req.body.code))
|
||||||
|
)
|
||||||
|
throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
|
||||||
|
|
||||||
|
bot.data = { hash: undefined, valid_tokens_since: new Date() };
|
||||||
|
|
||||||
|
await bot.save();
|
||||||
|
|
||||||
|
const token = await generateToken(bot.id);
|
||||||
|
|
||||||
|
res.json({ token }).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "BotModifySchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Application",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as BotModifySchema;
|
||||||
|
if (!body.avatar?.trim()) delete body.avatar;
|
||||||
|
|
||||||
|
const app = await Application.findOneOrFail({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
relations: ["bot", "owner"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app.bot) throw DiscordApiErrors.BOT_ONLY_ENDPOINT;
|
||||||
|
|
||||||
|
if (app.owner.id != req.user_id)
|
||||||
|
throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
|
||||||
|
|
||||||
|
if (body.avatar)
|
||||||
|
body.avatar = await handleFile(
|
||||||
|
`/avatars/${app.id}`,
|
||||||
|
body.avatar as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.bot.assign(body);
|
||||||
|
|
||||||
|
app.bot.save();
|
||||||
|
|
||||||
|
await app.save();
|
||||||
|
res.json(app).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
40
src/api/routes/applications/#id/entitlements.ts
Normal file
40
src/api/routes/applications/#id/entitlements.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "ApplicationEntitlementsResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(req: Request, res: Response) => {
|
||||||
|
// TODO:
|
||||||
|
//const { exclude_consumed } = req.query;
|
||||||
|
res.status(200).send([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
157
src/api/routes/applications/#id/index.ts
Normal file
157
src/api/routes/applications/#id/index.ts
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Application,
|
||||||
|
ApplicationModifySchema,
|
||||||
|
DiscordApiErrors,
|
||||||
|
Guild,
|
||||||
|
handleFile,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { verifyToken } from "node-2fa";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Application",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const app = await Application.findOneOrFail({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
relations: ["owner", "bot"],
|
||||||
|
});
|
||||||
|
if (app.owner.id != req.user_id)
|
||||||
|
throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
|
||||||
|
|
||||||
|
return res.json(app);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "ApplicationModifySchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Application",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as ApplicationModifySchema;
|
||||||
|
|
||||||
|
const app = await Application.findOneOrFail({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
relations: ["owner", "bot"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (app.owner.id != req.user_id)
|
||||||
|
throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
|
||||||
|
|
||||||
|
if (
|
||||||
|
app.owner.totp_secret &&
|
||||||
|
(!req.body.code ||
|
||||||
|
verifyToken(app.owner.totp_secret, req.body.code))
|
||||||
|
)
|
||||||
|
throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
|
||||||
|
|
||||||
|
if (body.icon) {
|
||||||
|
body.icon = await handleFile(
|
||||||
|
`/app-icons/${app.id}`,
|
||||||
|
body.icon as string,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (body.cover_image) {
|
||||||
|
body.cover_image = await handleFile(
|
||||||
|
`/app-icons/${app.id}`,
|
||||||
|
body.cover_image as string,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.guild_id) {
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { id: body.guild_id },
|
||||||
|
select: ["owner_id"],
|
||||||
|
});
|
||||||
|
if (guild.owner_id != req.user_id)
|
||||||
|
throw new HTTPError(
|
||||||
|
"You must be the owner of the guild to link it to an application",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app.bot) {
|
||||||
|
app.bot.assign({ bio: body.description });
|
||||||
|
await app.bot.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.assign(body);
|
||||||
|
|
||||||
|
await app.save();
|
||||||
|
|
||||||
|
return res.json(app);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/delete",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const app = await Application.findOneOrFail({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
relations: ["bot", "owner"],
|
||||||
|
});
|
||||||
|
if (app.owner.id != req.user_id)
|
||||||
|
throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
|
||||||
|
|
||||||
|
if (
|
||||||
|
app.owner.totp_secret &&
|
||||||
|
(!req.body.code ||
|
||||||
|
verifyToken(app.owner.totp_secret, req.body.code))
|
||||||
|
)
|
||||||
|
throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
|
||||||
|
|
||||||
|
await Application.delete({ id: app.id });
|
||||||
|
|
||||||
|
res.send().status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
38
src/api/routes/applications/#id/skus.ts
Normal file
38
src/api/routes/applications/#id/skus.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "ApplicationSkusResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
res.json([]).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
39
src/api/routes/applications/detectable.ts
Normal file
39
src/api/routes/applications/detectable.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "ApplicationDetectableResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
//TODO
|
||||||
|
res.send([]).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
83
src/api/routes/applications/index.ts
Normal file
83
src/api/routes/applications/index.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Application,
|
||||||
|
ApplicationCreateSchema,
|
||||||
|
Config,
|
||||||
|
User,
|
||||||
|
createAppBotUser,
|
||||||
|
trimSpecial,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIApplicationArray",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const results = await Application.find({
|
||||||
|
where: { owner: { id: req.user_id } },
|
||||||
|
relations: ["owner", "bot"],
|
||||||
|
});
|
||||||
|
res.json(results).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "ApplicationCreateSchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Application",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as ApplicationCreateSchema;
|
||||||
|
const user = await User.findOneOrFail({ where: { id: req.user_id } });
|
||||||
|
|
||||||
|
const app = Application.create({
|
||||||
|
name: trimSpecial(body.name),
|
||||||
|
description: "",
|
||||||
|
bot_public: true,
|
||||||
|
owner: user,
|
||||||
|
verify_key: "IMPLEMENTME",
|
||||||
|
flags: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// april 14, 2023: discord made bot users be automatically added to all new apps
|
||||||
|
const { autoCreateBotUsers } = Config.get().general;
|
||||||
|
if (autoCreateBotUsers) {
|
||||||
|
await createAppBotUser(app, req);
|
||||||
|
} else await app.save();
|
||||||
|
|
||||||
|
res.json(app);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
81
src/api/routes/auth/forgot.ts
Normal file
81
src/api/routes/auth/forgot.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getIpAdress, route, verifyCaptcha } from "@valkyrie/api";
|
||||||
|
import { Config, Email, ForgotPasswordSchema, User } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "ForgotPasswordSchema",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorOrCaptchaResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { login, captcha_key } = req.body as ForgotPasswordSchema;
|
||||||
|
|
||||||
|
const config = Config.get();
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.passwordReset.requireCaptcha &&
|
||||||
|
config.security.captcha.enabled
|
||||||
|
) {
|
||||||
|
const { sitekey, service } = config.security.captcha;
|
||||||
|
if (!captcha_key) {
|
||||||
|
return res.status(400).json({
|
||||||
|
captcha_key: ["captcha-required"],
|
||||||
|
captcha_sitekey: sitekey,
|
||||||
|
captcha_service: service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = getIpAdress(req);
|
||||||
|
const verify = await verifyCaptcha(captcha_key, ip);
|
||||||
|
if (!verify.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
captcha_key: verify["error-codes"],
|
||||||
|
captcha_sitekey: sitekey,
|
||||||
|
captcha_service: service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: [{ phone: login }, { email: login }],
|
||||||
|
select: ["username", "id", "email"],
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
if (user && user.email) {
|
||||||
|
Email.sendResetPassword(user, user.email).catch((e) => {
|
||||||
|
console.error(
|
||||||
|
`Failed to send password reset email to ${user.username}#${user.discriminator} (${user.id}): ${e}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
82
src/api/routes/auth/generate-registration-tokens.ts
Normal file
82
src/api/routes/auth/generate-registration-tokens.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { random, route } from "@valkyrie/api";
|
||||||
|
import { Config, ValidRegistrationToken } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
query: {
|
||||||
|
count: {
|
||||||
|
type: "number",
|
||||||
|
description:
|
||||||
|
"The number of registration tokens to generate. Defaults to 1.",
|
||||||
|
},
|
||||||
|
length: {
|
||||||
|
type: "number",
|
||||||
|
description:
|
||||||
|
"The length of each registration token. Defaults to 255.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: "CREATE_REGISTRATION_TOKENS",
|
||||||
|
responses: { 200: { body: "GenerateRegistrationTokensResponse" } },
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const count = req.query.count ? parseInt(req.query.count as string) : 1;
|
||||||
|
const length = req.query.length
|
||||||
|
? parseInt(req.query.length as string)
|
||||||
|
: 255;
|
||||||
|
|
||||||
|
const tokens: ValidRegistrationToken[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const token = ValidRegistrationToken.create({
|
||||||
|
token: random(length),
|
||||||
|
expires_at:
|
||||||
|
Date.now() +
|
||||||
|
Config.get().security.defaultRegistrationTokenExpiration,
|
||||||
|
});
|
||||||
|
tokens.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why are these options used, exactly?
|
||||||
|
await ValidRegistrationToken.save(tokens, {
|
||||||
|
chunk: 1000,
|
||||||
|
reload: false,
|
||||||
|
transaction: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ret = req.query.include_url
|
||||||
|
? tokens.map(
|
||||||
|
(x) =>
|
||||||
|
`${Config.get().general.frontPage}/register?token=${
|
||||||
|
x.token
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
: tokens.map((x) => x.token);
|
||||||
|
|
||||||
|
if (req.query.plain) return res.send(ret.join("\n"));
|
||||||
|
|
||||||
|
return res.json({ tokens: ret });
|
||||||
|
},
|
||||||
|
);
|
44
src/api/routes/auth/location-metadata.ts
Normal file
44
src/api/routes/auth/location-metadata.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IPAnalysis, getIpAdress, route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "LocationMetadataResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
//TODO
|
||||||
|
//Note: It's most likely related to legal. At the moment Discord hasn't finished this too
|
||||||
|
const country_code = (await IPAnalysis(getIpAdress(req))).country_code;
|
||||||
|
res.json({
|
||||||
|
consent_required: false,
|
||||||
|
country_code: country_code,
|
||||||
|
promotional_email_opt_in: { required: true, pre_checked: false },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
225
src/api/routes/auth/login.ts
Normal file
225
src/api/routes/auth/login.ts
Normal file
|
@ -0,0 +1,225 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getIpAdress, route, verifyCaptcha } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
FieldErrors,
|
||||||
|
LoginSchema,
|
||||||
|
User,
|
||||||
|
WebAuthn,
|
||||||
|
generateToken,
|
||||||
|
generateWebAuthnTicket,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "LoginSchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "LoginResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorOrCaptchaResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { login, password, captcha_key, undelete } =
|
||||||
|
req.body as LoginSchema;
|
||||||
|
|
||||||
|
const config = Config.get();
|
||||||
|
|
||||||
|
if (config.login.requireCaptcha && config.security.captcha.enabled) {
|
||||||
|
const { sitekey, service } = config.security.captcha;
|
||||||
|
if (!captcha_key) {
|
||||||
|
return res.status(400).json({
|
||||||
|
captcha_key: ["captcha-required"],
|
||||||
|
captcha_sitekey: sitekey,
|
||||||
|
captcha_service: service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = getIpAdress(req);
|
||||||
|
const verify = await verifyCaptcha(captcha_key, ip);
|
||||||
|
if (!verify.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
captcha_key: verify["error-codes"],
|
||||||
|
captcha_sitekey: sitekey,
|
||||||
|
captcha_service: service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOneOrFail({
|
||||||
|
where: [{ phone: login }, { email: login }],
|
||||||
|
select: [
|
||||||
|
"data",
|
||||||
|
"id",
|
||||||
|
"disabled",
|
||||||
|
"deleted",
|
||||||
|
"totp_secret",
|
||||||
|
"mfa_enabled",
|
||||||
|
"webauthn_enabled",
|
||||||
|
"security_keys",
|
||||||
|
"verified",
|
||||||
|
],
|
||||||
|
relations: ["security_keys", "settings"],
|
||||||
|
}).catch(() => {
|
||||||
|
throw FieldErrors({
|
||||||
|
login: {
|
||||||
|
message: req.t("auth:login.INVALID_LOGIN"),
|
||||||
|
code: "INVALID_LOGIN",
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
message: req.t("auth:login.INVALID_LOGIN"),
|
||||||
|
code: "INVALID_LOGIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// the salt is saved in the password refer to bcrypt docs
|
||||||
|
const same_password = await bcrypt.compare(
|
||||||
|
password,
|
||||||
|
user.data.hash || "",
|
||||||
|
);
|
||||||
|
if (!same_password) {
|
||||||
|
throw FieldErrors({
|
||||||
|
login: {
|
||||||
|
message: req.t("auth:login.INVALID_LOGIN"),
|
||||||
|
code: "INVALID_LOGIN",
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
message: req.t("auth:login.INVALID_LOGIN"),
|
||||||
|
code: "INVALID_LOGIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// return an error for unverified accounts if verification is required
|
||||||
|
if (config.login.requireVerification && !user.verified) {
|
||||||
|
throw FieldErrors({
|
||||||
|
login: {
|
||||||
|
code: "ACCOUNT_LOGIN_VERIFICATION_EMAIL",
|
||||||
|
message:
|
||||||
|
"Email verification is required, please check your email.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.mfa_enabled && !user.webauthn_enabled) {
|
||||||
|
// TODO: This is not a discord.com ticket. I'm not sure what it is but I'm lazy
|
||||||
|
const ticket = crypto.randomBytes(40).toString("hex");
|
||||||
|
|
||||||
|
await User.update({ id: user.id }, { totp_last_ticket: ticket });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ticket: ticket,
|
||||||
|
mfa: true,
|
||||||
|
sms: false, // TODO
|
||||||
|
token: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.mfa_enabled && user.webauthn_enabled) {
|
||||||
|
if (!WebAuthn.fido2) {
|
||||||
|
// TODO: I did this for typescript and I can't use !
|
||||||
|
throw new Error("WebAuthn not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = await WebAuthn.fido2.assertionOptions();
|
||||||
|
const challenge = JSON.stringify({
|
||||||
|
publicKey: {
|
||||||
|
...options,
|
||||||
|
challenge: Buffer.from(options.challenge).toString(
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
allowCredentials: user.security_keys.map((x) => ({
|
||||||
|
id: x.key_id,
|
||||||
|
type: "public-key",
|
||||||
|
})),
|
||||||
|
transports: ["usb", "ble", "nfc"],
|
||||||
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticket = await generateWebAuthnTicket(challenge);
|
||||||
|
await User.update({ id: user.id }, { totp_last_ticket: ticket });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ticket: ticket,
|
||||||
|
mfa: true,
|
||||||
|
sms: false, // TODO
|
||||||
|
token: null,
|
||||||
|
webauthn: challenge,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (undelete) {
|
||||||
|
// undelete refers to un'disable' here
|
||||||
|
if (user.disabled)
|
||||||
|
await User.update({ id: user.id }, { disabled: false });
|
||||||
|
if (user.deleted)
|
||||||
|
await User.update({ id: user.id }, { deleted: false });
|
||||||
|
} else {
|
||||||
|
if (user.deleted)
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "This account is scheduled for deletion.",
|
||||||
|
code: 20011,
|
||||||
|
});
|
||||||
|
if (user.disabled)
|
||||||
|
return res.status(400).json({
|
||||||
|
message: req.t("auth:login.ACCOUNT_DISABLED"),
|
||||||
|
code: 20013,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await generateToken(user.id);
|
||||||
|
|
||||||
|
// Notice this will have a different token structure, than discord
|
||||||
|
// Discord header is just the user id as string, which is not possible with npm-jsonwebtoken package
|
||||||
|
// https://user-images.githubusercontent.com/6506416/81051916-dd8c9900-8ec2-11ea-8794-daf12d6f31f0.png
|
||||||
|
|
||||||
|
res.json({ token, settings: { ...user.settings, index: undefined } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/login
|
||||||
|
* @argument { login: "email@gmail.com", password: "cleartextpassword", undelete: false, captcha_key: null, login_source: null, gift_code_sku_id: null, }
|
||||||
|
|
||||||
|
* MFA required:
|
||||||
|
* @returns {"token": null, "mfa": true, "sms": true, "ticket": "SOME TICKET JWT TOKEN"}
|
||||||
|
|
||||||
|
* WebAuthn MFA required:
|
||||||
|
* @returns {"token": null, "mfa": true, "webauthn": true, "sms": true, "ticket": "SOME TICKET JWT TOKEN"}
|
||||||
|
|
||||||
|
* Captcha required:
|
||||||
|
* @returns {"captcha_key": ["captcha-required"], "captcha_sitekey": null, "captcha_service": "recaptcha"}
|
||||||
|
|
||||||
|
* Sucess:
|
||||||
|
* @returns {"token": "USERTOKEN", "settings": {"locale": "en", "theme": "dark"}}
|
||||||
|
|
||||||
|
*/
|
46
src/api/routes/auth/logout.ts
Normal file
46
src/api/routes/auth/logout.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
if (req.body.provider != null || req.body.voip_provider != null) {
|
||||||
|
console.log(
|
||||||
|
`[LOGOUT]: provider or voip provider not null!`,
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
delete req.body.provider;
|
||||||
|
delete req.body.voip_provider;
|
||||||
|
if (Object.keys(req.body).length != 0)
|
||||||
|
console.log(`[LOGOUT]: Extra fields sent in logout!`, req.body);
|
||||||
|
}
|
||||||
|
res.status(204).send();
|
||||||
|
},
|
||||||
|
);
|
81
src/api/routes/auth/mfa/totp.ts
Normal file
81
src/api/routes/auth/mfa/totp.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { BackupCode, TotpSchema, User, generateToken } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { verifyToken } from "node-2fa";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "TotpSchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "TokenResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// const { code, ticket, gift_code_sku_id, login_source } =
|
||||||
|
const { code, ticket } = req.body as TotpSchema;
|
||||||
|
|
||||||
|
const user = await User.findOneOrFail({
|
||||||
|
where: {
|
||||||
|
totp_last_ticket: ticket,
|
||||||
|
},
|
||||||
|
select: ["id", "totp_secret"],
|
||||||
|
relations: ["settings"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const backup = await BackupCode.findOne({
|
||||||
|
where: {
|
||||||
|
code: code,
|
||||||
|
expired: false,
|
||||||
|
consumed: false,
|
||||||
|
user: { id: user.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
const ret = verifyToken(user.totp_secret || "", code);
|
||||||
|
if (!ret || ret.delta != 0)
|
||||||
|
throw new HTTPError(
|
||||||
|
req.t("auth:login.INVALID_TOTP_CODE"),
|
||||||
|
60008,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
backup.consumed = true;
|
||||||
|
await backup.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
await User.update({ id: user.id }, { totp_last_ticket: "" });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
token: await generateToken(user.id),
|
||||||
|
settings: { ...user.settings, index: undefined },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
122
src/api/routes/auth/mfa/webauthn.ts
Normal file
122
src/api/routes/auth/mfa/webauthn.ts
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
generateToken,
|
||||||
|
SecurityKey,
|
||||||
|
User,
|
||||||
|
verifyWebAuthnToken,
|
||||||
|
WebAuthn,
|
||||||
|
WebAuthnTotpSchema,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { ExpectedAssertionResult } from "fido2-lib";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
function toArrayBuffer(buf: Buffer) {
|
||||||
|
const ab = new ArrayBuffer(buf.length);
|
||||||
|
const view = new Uint8Array(ab);
|
||||||
|
for (let i = 0; i < buf.length; ++i) {
|
||||||
|
view[i] = buf[i];
|
||||||
|
}
|
||||||
|
return ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "WebAuthnTotpSchema",
|
||||||
|
responses: {
|
||||||
|
200: { body: "TokenResponse" },
|
||||||
|
400: { body: "APIErrorResponse" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
if (!WebAuthn.fido2) {
|
||||||
|
// TODO: I did this for typescript and I can't use !
|
||||||
|
throw new Error("WebAuthn not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, ticket } = req.body as WebAuthnTotpSchema;
|
||||||
|
|
||||||
|
const user = await User.findOneOrFail({
|
||||||
|
where: {
|
||||||
|
totp_last_ticket: ticket,
|
||||||
|
},
|
||||||
|
select: ["id"],
|
||||||
|
relations: ["settings"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ret = await verifyWebAuthnToken(ticket);
|
||||||
|
if (!ret)
|
||||||
|
throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
|
||||||
|
|
||||||
|
await User.update({ id: user.id }, { totp_last_ticket: "" });
|
||||||
|
|
||||||
|
const clientAttestationResponse = JSON.parse(code);
|
||||||
|
|
||||||
|
if (!clientAttestationResponse.rawId)
|
||||||
|
throw new HTTPError("Missing rawId", 400);
|
||||||
|
|
||||||
|
clientAttestationResponse.rawId = toArrayBuffer(
|
||||||
|
Buffer.from(clientAttestationResponse.rawId, "base64url"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const securityKey = await SecurityKey.findOneOrFail({
|
||||||
|
where: {
|
||||||
|
key_id: Buffer.from(
|
||||||
|
clientAttestationResponse.rawId,
|
||||||
|
"base64url",
|
||||||
|
).toString("base64"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assertionExpectations: ExpectedAssertionResult = JSON.parse(
|
||||||
|
Buffer.from(
|
||||||
|
clientAttestationResponse.response.clientDataJSON,
|
||||||
|
"base64",
|
||||||
|
).toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authnResult = await WebAuthn.fido2.assertionResult(
|
||||||
|
clientAttestationResponse,
|
||||||
|
{
|
||||||
|
...assertionExpectations,
|
||||||
|
factor: "second",
|
||||||
|
publicKey: securityKey.public_key,
|
||||||
|
prevCounter: securityKey.counter,
|
||||||
|
userHandle: securityKey.key_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const counter = authnResult.authnrData.get("counter");
|
||||||
|
|
||||||
|
securityKey.counter = counter;
|
||||||
|
|
||||||
|
await securityKey.save();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
token: await generateToken(user.id),
|
||||||
|
user_settings: user.settings,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
322
src/api/routes/auth/register.ts
Normal file
322
src/api/routes/auth/register.ts
Normal file
|
@ -0,0 +1,322 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IPAnalysis,
|
||||||
|
getIpAdress,
|
||||||
|
isProxy,
|
||||||
|
route,
|
||||||
|
verifyCaptcha,
|
||||||
|
} from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
FieldErrors,
|
||||||
|
Invite,
|
||||||
|
RegisterSchema,
|
||||||
|
User,
|
||||||
|
ValidRegistrationToken,
|
||||||
|
generateToken,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { MoreThan } from "typeorm";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "RegisterSchema",
|
||||||
|
responses: {
|
||||||
|
200: { body: "TokenOnlyResponse" },
|
||||||
|
400: { body: "APIErrorOrCaptchaResponse" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as RegisterSchema;
|
||||||
|
const { register, security, limits } = Config.get();
|
||||||
|
const ip = getIpAdress(req);
|
||||||
|
|
||||||
|
// Reg tokens
|
||||||
|
// They're a one time use token that bypasses registration limits ( rates, disabled reg, etc )
|
||||||
|
let regTokenUsed = false;
|
||||||
|
if (req.get("Referrer") && req.get("Referrer")?.includes("token=")) {
|
||||||
|
// eg theyre on https://staging.valkyriecoms.com/register?token=whatever
|
||||||
|
const token = req.get("Referrer")?.split("token=")[1].split("&")[0];
|
||||||
|
if (token) {
|
||||||
|
const regToken = await ValidRegistrationToken.findOneOrFail({
|
||||||
|
where: { token, expires_at: MoreThan(new Date()) },
|
||||||
|
});
|
||||||
|
await regToken.remove();
|
||||||
|
regTokenUsed = true;
|
||||||
|
console.log(
|
||||||
|
`[REGISTER] Registration token ${token} used for registration!`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[REGISTER] Invalid registration token ${token} used for registration by ${ip}!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if registration is allowed
|
||||||
|
if (!regTokenUsed && !register.allowNewRegistration) {
|
||||||
|
throw FieldErrors({
|
||||||
|
email: {
|
||||||
|
code: "REGISTRATION_DISABLED",
|
||||||
|
message: req.t("auth:register.REGISTRATION_DISABLED"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the user agreed to the Terms of Service
|
||||||
|
if (!body.consent) {
|
||||||
|
throw FieldErrors({
|
||||||
|
consent: {
|
||||||
|
code: "CONSENT_REQUIRED",
|
||||||
|
message: req.t("auth:register.CONSENT_REQUIRED"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!regTokenUsed && register.disabled) {
|
||||||
|
throw FieldErrors({
|
||||||
|
email: {
|
||||||
|
code: "DISABLED",
|
||||||
|
message: "registration is disabled on this instance",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!regTokenUsed &&
|
||||||
|
register.requireCaptcha &&
|
||||||
|
security.captcha.enabled
|
||||||
|
) {
|
||||||
|
const { sitekey, service } = security.captcha;
|
||||||
|
if (!body.captcha_key) {
|
||||||
|
return res?.status(400).json({
|
||||||
|
captcha_key: ["captcha-required"],
|
||||||
|
captcha_sitekey: sitekey,
|
||||||
|
captcha_service: service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const verify = await verifyCaptcha(body.captcha_key, ip);
|
||||||
|
if (!verify.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
captcha_key: verify["error-codes"],
|
||||||
|
captcha_sitekey: sitekey,
|
||||||
|
captcha_service: service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!regTokenUsed && !register.allowMultipleAccounts) {
|
||||||
|
// TODO: check if fingerprint was eligible generated
|
||||||
|
const exists = await User.findOne({
|
||||||
|
where: { fingerprints: body.fingerprint },
|
||||||
|
select: ["id"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
throw FieldErrors({
|
||||||
|
email: {
|
||||||
|
code: "EMAIL_ALREADY_REGISTERED",
|
||||||
|
message: req.t(
|
||||||
|
"auth:register.EMAIL_ALREADY_REGISTERED",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!regTokenUsed && register.blockProxies) {
|
||||||
|
if (isProxy(await IPAnalysis(ip))) {
|
||||||
|
console.log(`proxy ${ip} blocked from registration`);
|
||||||
|
throw new HTTPError("Your IP is blocked from registration");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: gift_code_sku_id?
|
||||||
|
// TODO: check password strength
|
||||||
|
|
||||||
|
const email = body.email;
|
||||||
|
if (email) {
|
||||||
|
// replace all dots and chars after +, if its a gmail.com email
|
||||||
|
if (!email) {
|
||||||
|
throw FieldErrors({
|
||||||
|
email: {
|
||||||
|
code: "INVALID_EMAIL",
|
||||||
|
message: req?.t("auth:register.INVALID_EMAIL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if there is already an account with this email
|
||||||
|
const exists = await User.findOne({ where: { email: email } });
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
throw FieldErrors({
|
||||||
|
email: {
|
||||||
|
code: "EMAIL_ALREADY_REGISTERED",
|
||||||
|
message: req.t(
|
||||||
|
"auth:register.EMAIL_ALREADY_REGISTERED",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (register.email.required) {
|
||||||
|
throw FieldErrors({
|
||||||
|
email: {
|
||||||
|
code: "BASE_TYPE_REQUIRED",
|
||||||
|
message: req.t("common:field.BASE_TYPE_REQUIRED"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (register.dateOfBirth.required && !body.date_of_birth) {
|
||||||
|
throw FieldErrors({
|
||||||
|
date_of_birth: {
|
||||||
|
code: "BASE_TYPE_REQUIRED",
|
||||||
|
message: req.t("common:field.BASE_TYPE_REQUIRED"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
register.dateOfBirth.required &&
|
||||||
|
register.dateOfBirth.minimum
|
||||||
|
) {
|
||||||
|
const minimum = new Date();
|
||||||
|
minimum.setFullYear(
|
||||||
|
minimum.getFullYear() - register.dateOfBirth.minimum,
|
||||||
|
);
|
||||||
|
body.date_of_birth = new Date(body.date_of_birth as Date);
|
||||||
|
|
||||||
|
// higher is younger
|
||||||
|
if (body.date_of_birth > minimum) {
|
||||||
|
throw FieldErrors({
|
||||||
|
date_of_birth: {
|
||||||
|
code: "DATE_OF_BIRTH_UNDERAGE",
|
||||||
|
message: req.t("auth:register.DATE_OF_BIRTH_UNDERAGE", {
|
||||||
|
years: register.dateOfBirth.minimum,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.password) {
|
||||||
|
const min = register.password.minLength ?? 8;
|
||||||
|
|
||||||
|
if (body.password.length < min) {
|
||||||
|
throw FieldErrors({
|
||||||
|
password: {
|
||||||
|
code: "PASSWORD_REQUIREMENTS_MIN_LENGTH",
|
||||||
|
message: req.t(
|
||||||
|
"auth:register.PASSWORD_REQUIREMENTS_MIN_LENGTH",
|
||||||
|
{ min: min },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// the salt is saved in the password refer to bcrypt docs
|
||||||
|
body.password = await bcrypt.hash(body.password, 12);
|
||||||
|
} else if (register.password.required) {
|
||||||
|
throw FieldErrors({
|
||||||
|
password: {
|
||||||
|
code: "BASE_TYPE_REQUIRED",
|
||||||
|
message: req.t("common:field.BASE_TYPE_REQUIRED"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!regTokenUsed &&
|
||||||
|
!body.invite &&
|
||||||
|
(register.requireInvite ||
|
||||||
|
(register.guestsRequireInvite && !register.email))
|
||||||
|
) {
|
||||||
|
// require invite to register -> e.g. for organizations to send invites to their employees
|
||||||
|
throw FieldErrors({
|
||||||
|
email: {
|
||||||
|
code: "INVITE_ONLY",
|
||||||
|
message: req.t("auth:register.INVITE_ONLY"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!regTokenUsed &&
|
||||||
|
limits.absoluteRate.register.enabled &&
|
||||||
|
(await User.count({
|
||||||
|
where: {
|
||||||
|
created_at: MoreThan(
|
||||||
|
new Date(
|
||||||
|
Date.now() - limits.absoluteRate.register.window,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})) >= limits.absoluteRate.register.limit
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`Global register ratelimit exceeded for ${getIpAdress(req)}, ${
|
||||||
|
req.body.username
|
||||||
|
}, ${req.body.invite || "No invite given"}`,
|
||||||
|
);
|
||||||
|
throw FieldErrors({
|
||||||
|
email: {
|
||||||
|
code: "TOO_MANY_REGISTRATIONS",
|
||||||
|
message: req.t("auth:register.TOO_MANY_REGISTRATIONS"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { maxUsername } = Config.get().limits.user;
|
||||||
|
if (body.username.length > maxUsername) {
|
||||||
|
throw FieldErrors({
|
||||||
|
username: {
|
||||||
|
code: "BASE_TYPE_BAD_LENGTH",
|
||||||
|
message: `Must be between 2 and ${maxUsername} in length.`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.register({ ...body, req });
|
||||||
|
|
||||||
|
if (body.invite) {
|
||||||
|
// await to fail if the invite doesn't exist (necessary for requireInvite to work properly) (username only signups are possible)
|
||||||
|
await Invite.joinGuild(user.id, body.invite);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ token: await generateToken(user.id) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/register
|
||||||
|
* @argument { "fingerprint":"805826570869932034.wR8vi8lGlFBJerErO9LG5NViJFw", "email":"qo8etzvaf@gmail.com", "username":"qp39gr98", "password":"wtp9gep9gw", "invite":null, "consent":true, "date_of_birth":"2000-04-04", "gift_code_sku_id":null, "captcha_key":null}
|
||||||
|
*
|
||||||
|
* Field Error
|
||||||
|
* @returns { "code": 50035, "errors": { "consent": { "_errors": [{ "code": "CONSENT_REQUIRED", "message": "You must agree to Discord's Terms of Service and Privacy Policy." }]}}, "message": "Invalid Form Body"}
|
||||||
|
*
|
||||||
|
* Success 200:
|
||||||
|
* @returns {token: "OMITTED"}
|
||||||
|
*/
|
82
src/api/routes/auth/reset.ts
Normal file
82
src/api/routes/auth/reset.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
checkToken,
|
||||||
|
Email,
|
||||||
|
FieldErrors,
|
||||||
|
generateToken,
|
||||||
|
PasswordResetSchema,
|
||||||
|
User,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// TODO: the response interface also returns settings, but this route doesn't actually return that.
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "PasswordResetSchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "TokenOnlyResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorOrCaptchaResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { password, token } = req.body as PasswordResetSchema;
|
||||||
|
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
const userTokenData = await checkToken(token);
|
||||||
|
user = userTokenData.user;
|
||||||
|
} catch {
|
||||||
|
throw FieldErrors({
|
||||||
|
password: {
|
||||||
|
message: req.t("auth:password_reset.INVALID_TOKEN"),
|
||||||
|
code: "INVALID_TOKEN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// the salt is saved in the password refer to bcrypt docs
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
data: {
|
||||||
|
hash,
|
||||||
|
valid_tokens_since: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await User.update({ id: user.id }, data);
|
||||||
|
|
||||||
|
// come on, the user has to have an email to reset their password in the first place
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
await Email.sendPasswordChanged(user, user.email!);
|
||||||
|
|
||||||
|
res.json({ token: await generateToken(user.id) });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
103
src/api/routes/auth/verify/index.ts
Normal file
103
src/api/routes/auth/verify/index.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getIpAdress, route, verifyCaptcha } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
checkToken,
|
||||||
|
Config,
|
||||||
|
FieldErrors,
|
||||||
|
generateToken,
|
||||||
|
User,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
async function getToken(user: User) {
|
||||||
|
const token = await generateToken(user.id);
|
||||||
|
|
||||||
|
// Notice this will have a different token structure, than discord
|
||||||
|
// Discord header is just the user id as string, which is not possible with npm-jsonwebtoken package
|
||||||
|
// https://user-images.githubusercontent.com/6506416/81051916-dd8c9900-8ec2-11ea-8794-daf12d6f31f0.png
|
||||||
|
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: the response interface also returns settings, but this route doesn't actually return that.
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "VerifyEmailSchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "TokenResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorOrCaptchaResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { captcha_key, token } = req.body;
|
||||||
|
|
||||||
|
const config = Config.get();
|
||||||
|
|
||||||
|
if (config.register.requireCaptcha && config.security.captcha.enabled) {
|
||||||
|
const { sitekey, service } = config.security.captcha;
|
||||||
|
|
||||||
|
if (!captcha_key) {
|
||||||
|
return res.status(400).json({
|
||||||
|
captcha_key: ["captcha-required"],
|
||||||
|
captcha_sitekey: sitekey,
|
||||||
|
captcha_service: service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = getIpAdress(req);
|
||||||
|
const verify = await verifyCaptcha(captcha_key, ip);
|
||||||
|
if (!verify.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
captcha_key: verify["error-codes"],
|
||||||
|
captcha_sitekey: sitekey,
|
||||||
|
captcha_service: service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userTokenData = await checkToken(token);
|
||||||
|
user = userTokenData.user;
|
||||||
|
} catch {
|
||||||
|
throw FieldErrors({
|
||||||
|
token: {
|
||||||
|
message: req.t("auth:password_reset.INVALID_TOKEN"),
|
||||||
|
code: "INVALID_TOKEN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.verified) return res.json(await getToken(user));
|
||||||
|
|
||||||
|
await User.update({ id: user.id }, { verified: true });
|
||||||
|
|
||||||
|
return res.json(await getToken(user));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
63
src/api/routes/auth/verify/resend.ts
Normal file
63
src/api/routes/auth/verify/resend.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Email, User } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
right: "RESEND_VERIFICATION_EMAIL",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const user = await User.findOneOrFail({
|
||||||
|
where: { id: req.user_id },
|
||||||
|
select: ["username", "email"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.email) {
|
||||||
|
// TODO: whats the proper error response for this?
|
||||||
|
throw new HTTPError("User does not have an email address", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Email.sendVerifyEmail(user, user.email)
|
||||||
|
.then(() => {
|
||||||
|
return res.sendStatus(204);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(
|
||||||
|
`Failed to send verification email to ${user.username}#${user.discriminator}: ${e}`,
|
||||||
|
);
|
||||||
|
throw new HTTPError("Failed to send verification email", 500);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
58
src/api/routes/auth/verify/view-backup-codes-challenge.ts
Normal file
58
src/api/routes/auth/verify/view-backup-codes-challenge.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { BackupCodesChallengeSchema, FieldErrors, User } from "@valkyrie/util";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "BackupCodesChallengeSchema",
|
||||||
|
responses: {
|
||||||
|
200: { body: "BackupCodesChallengeResponse" },
|
||||||
|
400: { body: "APIErrorResponse" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { password } = req.body as BackupCodesChallengeSchema;
|
||||||
|
|
||||||
|
const user = await User.findOneOrFail({
|
||||||
|
where: { id: req.user_id },
|
||||||
|
select: ["data"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(await bcrypt.compare(password, user.data.hash || ""))) {
|
||||||
|
throw FieldErrors({
|
||||||
|
password: {
|
||||||
|
message: req.t("auth:login.INVALID_PASSWORD"),
|
||||||
|
code: "INVALID_PASSWORD",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
nonce: "NoncePlaceholder",
|
||||||
|
regenerate_nonce: "RegenNoncePlaceholder",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
32
src/api/routes/channels/#channel_id/followers.ts
Normal file
32
src/api/routes/channels/#channel_id/followers.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
const router: Router = Router();
|
||||||
|
// TODO:
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {"webhook_channel_id":"754001514330062952"}
|
||||||
|
*
|
||||||
|
* Creates a WebHook in the channel and returns the id of it
|
||||||
|
*
|
||||||
|
* @returns {"channel_id": "816382962056560690", "webhook_id": "834910735095037962"}
|
||||||
|
*/
|
173
src/api/routes/channels/#channel_id/index.ts
Normal file
173
src/api/routes/channels/#channel_id/index.ts
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
ChannelDeleteEvent,
|
||||||
|
ChannelModifySchema,
|
||||||
|
ChannelType,
|
||||||
|
ChannelUpdateEvent,
|
||||||
|
Recipient,
|
||||||
|
emitEvent,
|
||||||
|
handleFile,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
// TODO: delete channel
|
||||||
|
// TODO: Get channel
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "VIEW_CHANNEL",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Channel",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
if (!channel.guild_id) return res.send(channel);
|
||||||
|
|
||||||
|
channel.position = await Channel.calculatePosition(
|
||||||
|
channel_id,
|
||||||
|
channel.guild_id,
|
||||||
|
channel.guild,
|
||||||
|
);
|
||||||
|
return res.send(channel);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_CHANNELS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Channel",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
relations: ["recipients"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (channel.type === ChannelType.DM) {
|
||||||
|
const recipient = await Recipient.findOneOrFail({
|
||||||
|
where: { channel_id: channel_id, user_id: req.user_id },
|
||||||
|
});
|
||||||
|
recipient.closed = true;
|
||||||
|
await Promise.all([
|
||||||
|
recipient.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "CHANNEL_DELETE",
|
||||||
|
data: channel,
|
||||||
|
user_id: req.user_id,
|
||||||
|
} as ChannelDeleteEvent),
|
||||||
|
]);
|
||||||
|
} else if (channel.type === ChannelType.GROUP_DM) {
|
||||||
|
await Channel.removeRecipientFromChannel(channel, req.user_id);
|
||||||
|
} else {
|
||||||
|
if (channel.type == ChannelType.GUILD_CATEGORY) {
|
||||||
|
const channels = await Channel.find({
|
||||||
|
where: { parent_id: channel_id },
|
||||||
|
});
|
||||||
|
for await (const c of channels) {
|
||||||
|
c.parent_id = null;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
c.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "CHANNEL_UPDATE",
|
||||||
|
data: c,
|
||||||
|
channel_id: c.id,
|
||||||
|
} as ChannelUpdateEvent),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
Channel.deleteChannel(channel),
|
||||||
|
emitEvent({
|
||||||
|
event: "CHANNEL_DELETE",
|
||||||
|
data: channel,
|
||||||
|
channel_id,
|
||||||
|
} as ChannelDeleteEvent),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(channel);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "ChannelModifySchema",
|
||||||
|
permission: "MANAGE_CHANNELS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Channel",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const payload = req.body as ChannelModifySchema;
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
if (payload.icon)
|
||||||
|
payload.icon = await handleFile(
|
||||||
|
`/channel-icons/${channel_id}`,
|
||||||
|
payload.icon,
|
||||||
|
);
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
channel.assign(payload);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
channel.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "CHANNEL_UPDATE",
|
||||||
|
data: channel,
|
||||||
|
channel_id,
|
||||||
|
} as ChannelUpdateEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.send(channel);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
132
src/api/routes/channels/#channel_id/invites.ts
Normal file
132
src/api/routes/channels/#channel_id/invites.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { random, route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
Guild,
|
||||||
|
Invite,
|
||||||
|
InviteCreateEvent,
|
||||||
|
InviteCreateSchema,
|
||||||
|
PublicInviteRelation,
|
||||||
|
User,
|
||||||
|
emitEvent,
|
||||||
|
isTextChannel,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "InviteCreateSchema",
|
||||||
|
permission: "CREATE_INSTANT_INVITE",
|
||||||
|
right: "CREATE_INVITES",
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
body: "Invite",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { user_id } = req;
|
||||||
|
const body = req.body as InviteCreateSchema;
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
select: ["id", "name", "type", "guild_id"],
|
||||||
|
});
|
||||||
|
isTextChannel(channel.type);
|
||||||
|
|
||||||
|
if (!channel.guild_id) {
|
||||||
|
throw new HTTPError("This channel doesn't exist", 404);
|
||||||
|
}
|
||||||
|
const { guild_id } = channel;
|
||||||
|
|
||||||
|
const expires_at =
|
||||||
|
body.max_age == 0 || body.max_age == undefined
|
||||||
|
? undefined
|
||||||
|
: new Date(body.max_age * 1000 + Date.now());
|
||||||
|
|
||||||
|
const invite = await Invite.create({
|
||||||
|
code: random(),
|
||||||
|
temporary: body.temporary || true,
|
||||||
|
uses: 0,
|
||||||
|
max_uses: body.max_uses ? Math.max(0, body.max_uses) : 0,
|
||||||
|
max_age: body.max_age ? Math.max(0, body.max_age) : 0,
|
||||||
|
expires_at,
|
||||||
|
created_at: new Date(),
|
||||||
|
guild_id,
|
||||||
|
channel_id: channel_id,
|
||||||
|
inviter_id: user_id,
|
||||||
|
flags: body.flags ?? 0,
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
const data = invite.toJSON();
|
||||||
|
data.inviter = (await User.getPublicUser(req.user_id)).toPublicUser();
|
||||||
|
data.guild = await Guild.findOne({ where: { id: guild_id } });
|
||||||
|
data.channel = channel;
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "INVITE_CREATE",
|
||||||
|
data,
|
||||||
|
guild_id,
|
||||||
|
} as InviteCreateEvent);
|
||||||
|
|
||||||
|
res.status(201).send(data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_CHANNELS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIInviteArray",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!channel.guild_id) {
|
||||||
|
throw new HTTPError("This channel doesn't exist", 404);
|
||||||
|
}
|
||||||
|
const { guild_id } = channel;
|
||||||
|
|
||||||
|
const invites = await Invite.find({
|
||||||
|
where: { guild_id },
|
||||||
|
relations: PublicInviteRelation,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(invites);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
emitEvent,
|
||||||
|
getPermission,
|
||||||
|
MessageAckEvent,
|
||||||
|
ReadState,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// TODO: public read receipts & privacy scoping
|
||||||
|
// TODO: send read state event to all channel members
|
||||||
|
// TODO: advance-only notification cursor
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "MessageAcknowledgeSchema",
|
||||||
|
responses: {
|
||||||
|
200: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id, message_id } = req.params;
|
||||||
|
|
||||||
|
const permission = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
undefined,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
permission.hasThrow("VIEW_CHANNEL");
|
||||||
|
|
||||||
|
let read_state = await ReadState.findOne({
|
||||||
|
where: { user_id: req.user_id, channel_id },
|
||||||
|
});
|
||||||
|
if (!read_state)
|
||||||
|
read_state = ReadState.create({ user_id: req.user_id, channel_id });
|
||||||
|
read_state.last_message_id = message_id;
|
||||||
|
|
||||||
|
await read_state.save();
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "MESSAGE_ACK",
|
||||||
|
user_id: req.user_id,
|
||||||
|
data: {
|
||||||
|
channel_id,
|
||||||
|
message_id,
|
||||||
|
version: 3763,
|
||||||
|
},
|
||||||
|
} as MessageAckEvent);
|
||||||
|
|
||||||
|
res.json({ token: null });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_MESSAGES",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(req: Request, res: Response) => {
|
||||||
|
// TODO:
|
||||||
|
res.json({
|
||||||
|
id: "",
|
||||||
|
type: 0,
|
||||||
|
content: "",
|
||||||
|
channel_id: "",
|
||||||
|
author: {
|
||||||
|
id: "",
|
||||||
|
username: "",
|
||||||
|
avatar: "",
|
||||||
|
discriminator: "",
|
||||||
|
public_flags: 64,
|
||||||
|
},
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentions: [],
|
||||||
|
mention_roles: [],
|
||||||
|
pinned: false,
|
||||||
|
mention_everyone: false,
|
||||||
|
tts: false,
|
||||||
|
timestamp: "",
|
||||||
|
edited_timestamp: null,
|
||||||
|
flags: 1,
|
||||||
|
components: [],
|
||||||
|
poll: {},
|
||||||
|
}).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,337 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Attachment,
|
||||||
|
Channel,
|
||||||
|
Message,
|
||||||
|
MessageCreateEvent,
|
||||||
|
MessageCreateSchema,
|
||||||
|
MessageDeleteEvent,
|
||||||
|
MessageEditSchema,
|
||||||
|
MessageUpdateEvent,
|
||||||
|
Snowflake,
|
||||||
|
SpacebarApiErrors,
|
||||||
|
emitEvent,
|
||||||
|
getPermission,
|
||||||
|
getRights,
|
||||||
|
uploadFile,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import multer from "multer";
|
||||||
|
import { handleMessage, postHandleMessage, route } from "../../../../../util";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
// TODO: message content/embed string length limit
|
||||||
|
|
||||||
|
const messageUpload = multer({
|
||||||
|
limits: {
|
||||||
|
fileSize: 1024 * 1024 * 100,
|
||||||
|
fields: 10,
|
||||||
|
files: 1,
|
||||||
|
},
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
}); // max upload 50 mb
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "MessageEditSchema",
|
||||||
|
permission: "SEND_MESSAGES",
|
||||||
|
right: "SEND_MESSAGES",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Message",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { message_id, channel_id } = req.params;
|
||||||
|
let body = req.body as MessageEditSchema;
|
||||||
|
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id, channel_id },
|
||||||
|
relations: ["attachments"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissions = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
undefined,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
|
||||||
|
if (req.user_id !== message.author_id) {
|
||||||
|
if (!rights.has("MANAGE_MESSAGES")) {
|
||||||
|
permissions.hasThrow("MANAGE_MESSAGES");
|
||||||
|
body = { flags: body.flags };
|
||||||
|
// guild admins can only suppress embeds of other messages, no such restriction imposed to instance-wide admins
|
||||||
|
}
|
||||||
|
} else rights.hasThrow("SELF_EDIT_MESSAGES");
|
||||||
|
|
||||||
|
// @ts-expect-error Something is wrong with message_reference here, TS complains since "channel_id" is optional in MessageCreateSchema
|
||||||
|
const new_message = await handleMessage({
|
||||||
|
...message,
|
||||||
|
// TODO: should message_reference be overridable?
|
||||||
|
message_reference: message.message_reference,
|
||||||
|
...body,
|
||||||
|
author_id: message.author_id,
|
||||||
|
channel_id,
|
||||||
|
id: message_id,
|
||||||
|
edited_timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
new_message.save(),
|
||||||
|
await emitEvent({
|
||||||
|
event: "MESSAGE_UPDATE",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
...new_message.toJSON(),
|
||||||
|
nonce: undefined,
|
||||||
|
member: new_message.member?.toPublicMember(),
|
||||||
|
},
|
||||||
|
} as MessageUpdateEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
postHandleMessage(new_message);
|
||||||
|
|
||||||
|
// TODO: a DTO?
|
||||||
|
return res.json({
|
||||||
|
...new_message.toJSON(),
|
||||||
|
id: new_message.id,
|
||||||
|
type: new_message.type,
|
||||||
|
channel_id: new_message.channel_id,
|
||||||
|
member: new_message.member?.toPublicMember(),
|
||||||
|
author: new_message.author?.toPublicUser(),
|
||||||
|
attachments: new_message.attachments,
|
||||||
|
embeds: new_message.embeds,
|
||||||
|
mentions: new_message.embeds,
|
||||||
|
mention_roles: new_message.mention_roles,
|
||||||
|
mention_everyone: new_message.mention_everyone,
|
||||||
|
pinned: new_message.pinned,
|
||||||
|
timestamp: new_message.timestamp,
|
||||||
|
edited_timestamp: new_message.edited_timestamp,
|
||||||
|
|
||||||
|
// these are not in the Discord.com response
|
||||||
|
mention_channels: new_message.mention_channels,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Backfill message with specific timestamp
|
||||||
|
router.put(
|
||||||
|
"/",
|
||||||
|
messageUpload.single("file"),
|
||||||
|
async (req, res, next) => {
|
||||||
|
if (req.body.payload_json) {
|
||||||
|
req.body = JSON.parse(req.body.payload_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
route({
|
||||||
|
requestBody: "MessageCreateSchema",
|
||||||
|
permission: "SEND_MESSAGES",
|
||||||
|
right: "SEND_BACKDATED_EVENTS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Message",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id, message_id } = req.params;
|
||||||
|
const body = req.body as MessageCreateSchema;
|
||||||
|
const attachments: Attachment[] = [];
|
||||||
|
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
rights.hasThrow("SEND_MESSAGES");
|
||||||
|
|
||||||
|
// regex to check if message contains anything other than numerals ( also no decimals )
|
||||||
|
if (!message_id.match(/^\+?\d+$/)) {
|
||||||
|
throw new HTTPError("Message IDs must be positive integers", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snowflake = Snowflake.deconstruct(message_id);
|
||||||
|
if (Date.now() < snowflake.timestamp) {
|
||||||
|
// message is in the future
|
||||||
|
throw SpacebarApiErrors.CANNOT_BACKFILL_TO_THE_FUTURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await Message.findOne({
|
||||||
|
where: { id: message_id, channel_id: channel_id },
|
||||||
|
});
|
||||||
|
if (exists) {
|
||||||
|
throw SpacebarApiErrors.CANNOT_REPLACE_BY_BACKFILL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.file) {
|
||||||
|
try {
|
||||||
|
const file = await uploadFile(
|
||||||
|
`/attachments/${req.params.channel_id}`,
|
||||||
|
req.file,
|
||||||
|
);
|
||||||
|
attachments.push(
|
||||||
|
Attachment.create({ ...file, proxy_url: file.url }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
relations: ["recipients", "recipients.user"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const embeds = body.embeds || [];
|
||||||
|
if (body.embed) embeds.push(body.embed);
|
||||||
|
const message = await handleMessage({
|
||||||
|
...body,
|
||||||
|
type: 0,
|
||||||
|
pinned: false,
|
||||||
|
author_id: req.user_id,
|
||||||
|
id: message_id,
|
||||||
|
embeds,
|
||||||
|
channel_id,
|
||||||
|
attachments,
|
||||||
|
edited_timestamp: undefined,
|
||||||
|
timestamp: new Date(snowflake.timestamp),
|
||||||
|
});
|
||||||
|
|
||||||
|
//Fix for the client bug
|
||||||
|
delete message.member;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
message.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "MESSAGE_CREATE",
|
||||||
|
channel_id: channel_id,
|
||||||
|
data: message,
|
||||||
|
} as MessageCreateEvent),
|
||||||
|
channel.save(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// no await as it shouldnt block the message send function and silently catch error
|
||||||
|
postHandleMessage(message).catch((e) =>
|
||||||
|
console.error("[Message] post-message handler failed", e),
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json(message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "VIEW_CHANNEL",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Message",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { message_id, channel_id } = req.params;
|
||||||
|
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id, channel_id },
|
||||||
|
relations: ["attachments"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissions = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
undefined,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (message.author_id !== req.user_id)
|
||||||
|
permissions.hasThrow("READ_MESSAGE_HISTORY");
|
||||||
|
|
||||||
|
return res.json(message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { message_id, channel_id } = req.params;
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
|
||||||
|
if (message.author_id !== req.user_id) {
|
||||||
|
if (!rights.has("MANAGE_MESSAGES")) {
|
||||||
|
const permission = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
channel.guild_id,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
permission.hasThrow("MANAGE_MESSAGES");
|
||||||
|
}
|
||||||
|
} else rights.hasThrow("SELF_DELETE_MESSAGES");
|
||||||
|
|
||||||
|
await Message.delete({ id: message_id });
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "MESSAGE_DELETE",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
id: message_id,
|
||||||
|
channel_id,
|
||||||
|
guild_id: channel.guild_id,
|
||||||
|
},
|
||||||
|
} as MessageDeleteEvent);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,404 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
emitEvent,
|
||||||
|
Emoji,
|
||||||
|
getPermission,
|
||||||
|
Member,
|
||||||
|
Message,
|
||||||
|
MessageReactionAddEvent,
|
||||||
|
MessageReactionRemoveAllEvent,
|
||||||
|
MessageReactionRemoveEmojiEvent,
|
||||||
|
MessageReactionRemoveEvent,
|
||||||
|
PartialEmoji,
|
||||||
|
PublicMemberProjection,
|
||||||
|
PublicUserProjection,
|
||||||
|
User,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { In } from "typeorm";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
// TODO: check if emoji is really an unicode emoji or a prperly encoded external emoji
|
||||||
|
|
||||||
|
function getEmoji(emoji: string): PartialEmoji {
|
||||||
|
emoji = decodeURIComponent(emoji);
|
||||||
|
const parts = emoji.includes(":") && emoji.split(":");
|
||||||
|
if (parts)
|
||||||
|
return {
|
||||||
|
name: parts[0],
|
||||||
|
id: parts[1],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: undefined,
|
||||||
|
name: emoji,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_MESSAGES",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { message_id, channel_id } = req.params;
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Message.update({ id: message_id, channel_id }, { reactions: [] });
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "MESSAGE_REACTION_REMOVE_ALL",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
channel_id,
|
||||||
|
message_id,
|
||||||
|
guild_id: channel.guild_id,
|
||||||
|
},
|
||||||
|
} as MessageReactionRemoveAllEvent);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:emoji",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_MESSAGES",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { message_id, channel_id } = req.params;
|
||||||
|
const emoji = getEmoji(req.params.emoji);
|
||||||
|
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id, channel_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const already_added = message.reactions.find(
|
||||||
|
(x) =>
|
||||||
|
(x.emoji.id === emoji.id && emoji.id) ||
|
||||||
|
x.emoji.name === emoji.name,
|
||||||
|
);
|
||||||
|
if (!already_added) throw new HTTPError("Reaction not found", 404);
|
||||||
|
message.reactions.remove(already_added);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
message.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "MESSAGE_REACTION_REMOVE_EMOJI",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
channel_id,
|
||||||
|
message_id,
|
||||||
|
guild_id: message.guild_id,
|
||||||
|
emoji,
|
||||||
|
},
|
||||||
|
} as MessageReactionRemoveEmojiEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:emoji",
|
||||||
|
route({
|
||||||
|
permission: "VIEW_CHANNEL",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "PublicUser",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { message_id, channel_id } = req.params;
|
||||||
|
const emoji = getEmoji(req.params.emoji);
|
||||||
|
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id, channel_id },
|
||||||
|
});
|
||||||
|
const reaction = message.reactions.find(
|
||||||
|
(x) =>
|
||||||
|
(x.emoji.id === emoji.id && emoji.id) ||
|
||||||
|
x.emoji.name === emoji.name,
|
||||||
|
);
|
||||||
|
if (!reaction) throw new HTTPError("Reaction not found", 404);
|
||||||
|
|
||||||
|
const users = await User.find({
|
||||||
|
where: {
|
||||||
|
id: In(reaction.user_ids),
|
||||||
|
},
|
||||||
|
select: PublicUserProjection,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/:emoji/:user_id",
|
||||||
|
route({
|
||||||
|
permission: "READ_MESSAGE_HISTORY",
|
||||||
|
right: "SELF_ADD_REACTIONS",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { message_id, channel_id, user_id } = req.params;
|
||||||
|
if (user_id !== "@me") throw new HTTPError("Invalid user");
|
||||||
|
const emoji = getEmoji(req.params.emoji);
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id, channel_id },
|
||||||
|
});
|
||||||
|
const already_added = message.reactions.find(
|
||||||
|
(x) =>
|
||||||
|
(x.emoji.id === emoji.id && emoji.id) ||
|
||||||
|
x.emoji.name === emoji.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!already_added) req.permission?.hasThrow("ADD_REACTIONS");
|
||||||
|
|
||||||
|
if (emoji.id) {
|
||||||
|
const external_emoji = await Emoji.findOneOrFail({
|
||||||
|
where: { id: emoji.id },
|
||||||
|
});
|
||||||
|
if (!already_added && channel.guild_id != external_emoji.guild_id)
|
||||||
|
req.permission?.hasThrow("USE_EXTERNAL_EMOJIS");
|
||||||
|
emoji.animated = external_emoji.animated;
|
||||||
|
emoji.name = external_emoji.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (already_added) {
|
||||||
|
if (already_added.user_ids.includes(req.user_id))
|
||||||
|
return res.sendStatus(204); // Do not throw an error ¯\_(ツ)_/¯ as discord also doesn't throw any error
|
||||||
|
already_added.count++;
|
||||||
|
already_added.user_ids.push(req.user_id);
|
||||||
|
} else
|
||||||
|
message.reactions.push({
|
||||||
|
count: 1,
|
||||||
|
emoji,
|
||||||
|
user_ids: [req.user_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
await message.save();
|
||||||
|
|
||||||
|
const member =
|
||||||
|
channel.guild_id &&
|
||||||
|
(
|
||||||
|
await Member.findOneOrFail({
|
||||||
|
where: { id: req.user_id },
|
||||||
|
select: PublicMemberProjection,
|
||||||
|
})
|
||||||
|
).toPublicMember();
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "MESSAGE_REACTION_ADD",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
user_id: req.user_id,
|
||||||
|
channel_id,
|
||||||
|
message_id,
|
||||||
|
guild_id: channel.guild_id,
|
||||||
|
emoji,
|
||||||
|
member,
|
||||||
|
},
|
||||||
|
} as MessageReactionAddEvent);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:emoji/:user_id",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
let { user_id } = req.params;
|
||||||
|
const { message_id, channel_id } = req.params;
|
||||||
|
|
||||||
|
const emoji = getEmoji(req.params.emoji);
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id, channel_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user_id === "@me") user_id = req.user_id;
|
||||||
|
else {
|
||||||
|
const permissions = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
undefined,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
permissions.hasThrow("MANAGE_MESSAGES");
|
||||||
|
}
|
||||||
|
|
||||||
|
const already_added = message.reactions.find(
|
||||||
|
(x) =>
|
||||||
|
(x.emoji.id === emoji.id && emoji.id) ||
|
||||||
|
x.emoji.name === emoji.name,
|
||||||
|
);
|
||||||
|
if (!already_added || !already_added.user_ids.includes(user_id))
|
||||||
|
throw new HTTPError("Reaction not found", 404);
|
||||||
|
|
||||||
|
already_added.count--;
|
||||||
|
|
||||||
|
if (already_added.count <= 0) message.reactions.remove(already_added);
|
||||||
|
else
|
||||||
|
already_added.user_ids.splice(
|
||||||
|
already_added.user_ids.indexOf(user_id),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
await message.save();
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "MESSAGE_REACTION_REMOVE",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
user_id: req.user_id,
|
||||||
|
channel_id,
|
||||||
|
message_id,
|
||||||
|
guild_id: channel.guild_id,
|
||||||
|
emoji,
|
||||||
|
},
|
||||||
|
} as MessageReactionRemoveEvent);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:emoji/:burst/:user_id",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
let { user_id } = req.params;
|
||||||
|
const { message_id, channel_id } = req.params;
|
||||||
|
|
||||||
|
const emoji = getEmoji(req.params.emoji);
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id, channel_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user_id === "@me") user_id = req.user_id;
|
||||||
|
else {
|
||||||
|
const permissions = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
undefined,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
permissions.hasThrow("MANAGE_MESSAGES");
|
||||||
|
}
|
||||||
|
|
||||||
|
const already_added = message.reactions.find(
|
||||||
|
(x) =>
|
||||||
|
(x.emoji.id === emoji.id && emoji.id) ||
|
||||||
|
x.emoji.name === emoji.name,
|
||||||
|
);
|
||||||
|
if (!already_added || !already_added.user_ids.includes(user_id))
|
||||||
|
throw new HTTPError("Reaction not found", 404);
|
||||||
|
|
||||||
|
already_added.count--;
|
||||||
|
|
||||||
|
if (already_added.count <= 0) message.reactions.remove(already_added);
|
||||||
|
else
|
||||||
|
already_added.user_ids.splice(
|
||||||
|
already_added.user_ids.indexOf(user_id),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
await message.save();
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "MESSAGE_REACTION_REMOVE",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
user_id: req.user_id,
|
||||||
|
channel_id,
|
||||||
|
message_id,
|
||||||
|
guild_id: channel.guild_id,
|
||||||
|
emoji,
|
||||||
|
},
|
||||||
|
} as MessageReactionRemoveEvent);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
93
src/api/routes/channels/#channel_id/messages/bulk-delete.ts
Normal file
93
src/api/routes/channels/#channel_id/messages/bulk-delete.ts
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
Config,
|
||||||
|
emitEvent,
|
||||||
|
getPermission,
|
||||||
|
getRights,
|
||||||
|
Message,
|
||||||
|
MessageDeleteBulkEvent,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
// should users be able to bulk delete messages or only bots? ANSWER: all users
|
||||||
|
// should this request fail, if you provide messages older than 14 days/invalid ids? ANSWER: NO
|
||||||
|
// https://discord.com/developers/docs/resources/channel#bulk-delete-messages
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "BulkDeleteSchema",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
if (!channel.guild_id)
|
||||||
|
throw new HTTPError("Can't bulk delete dm channel messages", 400);
|
||||||
|
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
rights.hasThrow("SELF_DELETE_MESSAGES");
|
||||||
|
|
||||||
|
const superuser = rights.has("MANAGE_MESSAGES");
|
||||||
|
const permission = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
channel?.guild_id,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { maxBulkDelete } = Config.get().limits.message;
|
||||||
|
|
||||||
|
const { messages } = req.body as { messages: string[] };
|
||||||
|
if (messages.length === 0)
|
||||||
|
throw new HTTPError("You must specify messages to bulk delete");
|
||||||
|
if (!superuser) {
|
||||||
|
permission.hasThrow("MANAGE_MESSAGES");
|
||||||
|
if (messages.length > maxBulkDelete)
|
||||||
|
throw new HTTPError(
|
||||||
|
`You cannot delete more than ${maxBulkDelete} messages`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Message.delete(messages);
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "MESSAGE_DELETE_BULK",
|
||||||
|
channel_id,
|
||||||
|
data: { ids: messages, channel_id, guild_id: channel.guild_id },
|
||||||
|
} as MessageDeleteBulkEvent);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
434
src/api/routes/channels/#channel_id/messages/index.ts
Normal file
434
src/api/routes/channels/#channel_id/messages/index.ts
Normal file
|
@ -0,0 +1,434 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { handleMessage, postHandleMessage, route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Attachment,
|
||||||
|
Channel,
|
||||||
|
Config,
|
||||||
|
DmChannelDTO,
|
||||||
|
FieldErrors,
|
||||||
|
Member,
|
||||||
|
Message,
|
||||||
|
MessageCreateEvent,
|
||||||
|
MessageCreateSchema,
|
||||||
|
Reaction,
|
||||||
|
ReadState,
|
||||||
|
Rights,
|
||||||
|
Snowflake,
|
||||||
|
User,
|
||||||
|
emitEvent,
|
||||||
|
getPermission,
|
||||||
|
isTextChannel,
|
||||||
|
uploadFile,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import multer from "multer";
|
||||||
|
import {
|
||||||
|
FindManyOptions,
|
||||||
|
FindOperator,
|
||||||
|
LessThan,
|
||||||
|
MoreThan,
|
||||||
|
MoreThanOrEqual,
|
||||||
|
} from "typeorm";
|
||||||
|
import { URL } from "url";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#create-message
|
||||||
|
// get messages
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
query: {
|
||||||
|
around: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
before: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description:
|
||||||
|
"max number of messages to return (1-100). defaults to 50",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIMessageArray",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const channel_id = req.params.channel_id;
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
if (!channel) throw new HTTPError("Channel not found", 404);
|
||||||
|
|
||||||
|
isTextChannel(channel.type);
|
||||||
|
const around = req.query.around ? `${req.query.around}` : undefined;
|
||||||
|
const before = req.query.before ? `${req.query.before}` : undefined;
|
||||||
|
const after = req.query.after ? `${req.query.after}` : undefined;
|
||||||
|
const limit = Number(req.query.limit) || 50;
|
||||||
|
if (limit < 1 || limit > 100)
|
||||||
|
throw new HTTPError("limit must be between 1 and 100", 422);
|
||||||
|
|
||||||
|
const permissions = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
channel.guild_id,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
permissions.hasThrow("VIEW_CHANNEL");
|
||||||
|
if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]);
|
||||||
|
|
||||||
|
const query: FindManyOptions<Message> & {
|
||||||
|
where: { id?: FindOperator<string> | FindOperator<string>[] };
|
||||||
|
} = {
|
||||||
|
order: { timestamp: "DESC" },
|
||||||
|
take: limit,
|
||||||
|
where: { channel_id },
|
||||||
|
relations: [
|
||||||
|
"author",
|
||||||
|
"webhook",
|
||||||
|
"application",
|
||||||
|
"mentions",
|
||||||
|
"mention_roles",
|
||||||
|
"mention_channels",
|
||||||
|
"sticker_items",
|
||||||
|
"attachments",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages: Message[];
|
||||||
|
|
||||||
|
if (around) {
|
||||||
|
query.take = Math.floor(limit / 2);
|
||||||
|
if (query.take != 0) {
|
||||||
|
const [right, left] = await Promise.all([
|
||||||
|
Message.find({
|
||||||
|
...query,
|
||||||
|
where: { channel_id, id: LessThan(around) },
|
||||||
|
}),
|
||||||
|
Message.find({
|
||||||
|
...query,
|
||||||
|
where: { channel_id, id: MoreThanOrEqual(around) },
|
||||||
|
order: { timestamp: "ASC" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
left.push(...right);
|
||||||
|
messages = left.sort(
|
||||||
|
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
query.take = 1;
|
||||||
|
const message = await Message.findOne({
|
||||||
|
...query,
|
||||||
|
where: { channel_id, id: around },
|
||||||
|
});
|
||||||
|
messages = message ? [message] : [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (after) {
|
||||||
|
if (BigInt(after) > BigInt(Snowflake.generate()))
|
||||||
|
throw new HTTPError(
|
||||||
|
"after parameter must not be greater than current time",
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
|
||||||
|
query.where.id = MoreThan(after);
|
||||||
|
query.order = { timestamp: "ASC" };
|
||||||
|
} else if (before) {
|
||||||
|
if (BigInt(before) > BigInt(Snowflake.generate()))
|
||||||
|
throw new HTTPError(
|
||||||
|
"before parameter must not be greater than current time",
|
||||||
|
422,
|
||||||
|
);
|
||||||
|
|
||||||
|
query.where.id = LessThan(before);
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = await Message.find(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = Config.get().cdn.endpointPublic;
|
||||||
|
|
||||||
|
const ret = messages.map((x: Message) => {
|
||||||
|
x = x.toJSON();
|
||||||
|
|
||||||
|
(x.reactions || []).forEach((y: Partial<Reaction>) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
if ((y.user_ids || []).includes(req.user_id)) y.me = true;
|
||||||
|
delete y.user_ids;
|
||||||
|
});
|
||||||
|
if (!x.author)
|
||||||
|
x.author = User.create({
|
||||||
|
id: "4",
|
||||||
|
discriminator: "0000",
|
||||||
|
username: "Spacebar Ghost",
|
||||||
|
public_flags: 0,
|
||||||
|
});
|
||||||
|
x.attachments?.forEach((y: Attachment) => {
|
||||||
|
// dynamically set attachment proxy_url in case the endpoint changed
|
||||||
|
const uri = y.proxy_url.startsWith("http")
|
||||||
|
? y.proxy_url
|
||||||
|
: `https://example.org${y.proxy_url}`;
|
||||||
|
|
||||||
|
let pathname = new URL(uri).pathname;
|
||||||
|
while (
|
||||||
|
pathname.split("/")[0] != "attachments" &&
|
||||||
|
pathname.length > 30
|
||||||
|
) {
|
||||||
|
pathname = pathname.split("/").slice(1).join("/");
|
||||||
|
}
|
||||||
|
if (!endpoint?.endsWith("/")) pathname = "/" + pathname;
|
||||||
|
|
||||||
|
y.proxy_url = `${endpoint == null ? "" : endpoint}${pathname}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
Some clients ( discord.js ) only check if a property exists within the response,
|
||||||
|
which causes errors when, say, the `application` property is `null`.
|
||||||
|
**/
|
||||||
|
|
||||||
|
// for (var curr in x) {
|
||||||
|
// if (x[curr] === null)
|
||||||
|
// delete x[curr];
|
||||||
|
// }
|
||||||
|
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(ret);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: config max upload size
|
||||||
|
const messageUpload = multer({
|
||||||
|
limits: {
|
||||||
|
fileSize: Config.get().limits.message.maxAttachmentSize,
|
||||||
|
fields: 10,
|
||||||
|
// files: 1
|
||||||
|
},
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
}); // max upload 50 mb
|
||||||
|
/**
|
||||||
|
TODO: dynamically change limit of MessageCreateSchema with config
|
||||||
|
|
||||||
|
https://discord.com/developers/docs/resources/channel#create-message
|
||||||
|
TODO: text channel slowdown (per-user and across-users)
|
||||||
|
Q: trim and replace message content and every embed field A: NO, given this cannot be implemented in E2EE channels
|
||||||
|
TODO: only dispatch notifications for mentions denoted in allowed_mentions
|
||||||
|
**/
|
||||||
|
// Send message
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
messageUpload.any(),
|
||||||
|
(req, res, next) => {
|
||||||
|
if (req.body.payload_json) {
|
||||||
|
req.body = JSON.parse(req.body.payload_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
route({
|
||||||
|
requestBody: "MessageCreateSchema",
|
||||||
|
permission: "SEND_MESSAGES",
|
||||||
|
right: "SEND_MESSAGES",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Message",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
const body = req.body as MessageCreateSchema;
|
||||||
|
const attachments: Attachment[] = [];
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
relations: ["recipients", "recipients.user"],
|
||||||
|
});
|
||||||
|
if (!channel.isWritable()) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`Cannot send messages to channel of type ${channel.type}`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.nonce) {
|
||||||
|
const existing = await Message.findOne({
|
||||||
|
where: {
|
||||||
|
nonce: body.nonce,
|
||||||
|
channel_id: channel.id,
|
||||||
|
author_id: req.user_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return res.json(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.rights.has(Rights.FLAGS.BYPASS_RATE_LIMITS)) {
|
||||||
|
const limits = Config.get().limits;
|
||||||
|
if (limits.absoluteRate.register.enabled) {
|
||||||
|
const count = await Message.count({
|
||||||
|
where: {
|
||||||
|
channel_id,
|
||||||
|
timestamp: MoreThan(
|
||||||
|
new Date(
|
||||||
|
Date.now() -
|
||||||
|
limits.absoluteRate.sendMessage.window,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count >= limits.absoluteRate.sendMessage.limit)
|
||||||
|
throw FieldErrors({
|
||||||
|
channel_id: {
|
||||||
|
code: "TOO_MANY_MESSAGES",
|
||||||
|
message: req.t("common:toomany.MESSAGE"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = (req.files as Express.Multer.File[]) ?? [];
|
||||||
|
for (const currFile of files) {
|
||||||
|
try {
|
||||||
|
const file = await uploadFile(
|
||||||
|
`/attachments/${channel.id}`,
|
||||||
|
currFile,
|
||||||
|
);
|
||||||
|
attachments.push(
|
||||||
|
Attachment.create({ ...file, proxy_url: file.url }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ message: error?.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const embeds = body.embeds || [];
|
||||||
|
if (body.embed) embeds.push(body.embed);
|
||||||
|
const message = await handleMessage({
|
||||||
|
...body,
|
||||||
|
type: 0,
|
||||||
|
pinned: false,
|
||||||
|
author_id: req.user_id,
|
||||||
|
embeds,
|
||||||
|
channel_id,
|
||||||
|
attachments,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore dont care2
|
||||||
|
message.edited_timestamp = null;
|
||||||
|
|
||||||
|
channel.last_message_id = message.id;
|
||||||
|
|
||||||
|
if (channel.isDm()) {
|
||||||
|
const channel_dto = await DmChannelDTO.from(channel);
|
||||||
|
|
||||||
|
// Only one recipients should be closed here, since in group DMs the recipient is deleted not closed
|
||||||
|
await Promise.all(
|
||||||
|
channel.recipients?.map((recipient) => {
|
||||||
|
if (recipient.closed) {
|
||||||
|
recipient.closed = false;
|
||||||
|
return Promise.all([
|
||||||
|
recipient.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "CHANNEL_CREATE",
|
||||||
|
data: channel_dto.excludedRecipients([
|
||||||
|
recipient.user_id,
|
||||||
|
]),
|
||||||
|
user_id: recipient.user_id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}) || [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.guild_id) {
|
||||||
|
// handleMessage will fetch the Member, but only if they are not guild owner.
|
||||||
|
// have to fetch ourselves otherwise.
|
||||||
|
if (!message.member) {
|
||||||
|
message.member = await Member.findOneOrFail({
|
||||||
|
where: { id: req.user_id, guild_id: message.guild_id },
|
||||||
|
relations: ["roles"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
message.member.roles = message.member.roles
|
||||||
|
.filter((x) => x.id != x.guild_id)
|
||||||
|
.map((x) => x.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let read_state = await ReadState.findOne({
|
||||||
|
where: { user_id: req.user_id, channel_id },
|
||||||
|
});
|
||||||
|
if (!read_state)
|
||||||
|
read_state = ReadState.create({ user_id: req.user_id, channel_id });
|
||||||
|
read_state.last_message_id = message.id;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
read_state.save(),
|
||||||
|
message.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "MESSAGE_CREATE",
|
||||||
|
channel_id: channel_id,
|
||||||
|
data: message,
|
||||||
|
} as MessageCreateEvent),
|
||||||
|
message.guild_id
|
||||||
|
? Member.update(
|
||||||
|
{ id: req.user_id, guild_id: message.guild_id },
|
||||||
|
{ last_message_id: message.id },
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
channel.save(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// no await as it shouldnt block the message send function and silently catch error
|
||||||
|
postHandleMessage(message).catch((e) =>
|
||||||
|
console.error("[Message] post-message handler failed", e),
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json(message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
132
src/api/routes/channels/#channel_id/permissions.ts
Normal file
132
src/api/routes/channels/#channel_id/permissions.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
ChannelPermissionOverwrite,
|
||||||
|
ChannelPermissionOverwriteSchema,
|
||||||
|
ChannelUpdateEvent,
|
||||||
|
emitEvent,
|
||||||
|
Member,
|
||||||
|
Role,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
// TODO: Only permissions your bot has in the guild or channel can be allowed/denied (unless your bot has a MANAGE_ROLES overwrite in the channel)
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/:overwrite_id",
|
||||||
|
route({
|
||||||
|
requestBody: "ChannelPermissionOverwriteSchema",
|
||||||
|
permission: "MANAGE_ROLES",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
404: {},
|
||||||
|
501: {},
|
||||||
|
400: { body: "APIErrorResponse" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id, overwrite_id } = req.params;
|
||||||
|
const body = req.body as ChannelPermissionOverwriteSchema;
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
if (!channel.guild_id) throw new HTTPError("Channel not found", 404);
|
||||||
|
channel.position = await Channel.calculatePosition(
|
||||||
|
channel_id,
|
||||||
|
channel.guild_id,
|
||||||
|
channel.guild,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (body.type === 0) {
|
||||||
|
if (!(await Role.count({ where: { id: overwrite_id } })))
|
||||||
|
throw new HTTPError("role not found", 404);
|
||||||
|
} else if (body.type === 1) {
|
||||||
|
if (!(await Member.count({ where: { id: overwrite_id } })))
|
||||||
|
throw new HTTPError("user not found", 404);
|
||||||
|
} else throw new HTTPError("type not supported", 501);
|
||||||
|
|
||||||
|
let overwrite: ChannelPermissionOverwrite | undefined =
|
||||||
|
channel.permission_overwrites?.find((x) => x.id === overwrite_id);
|
||||||
|
if (!overwrite) {
|
||||||
|
overwrite = {
|
||||||
|
id: overwrite_id,
|
||||||
|
type: body.type,
|
||||||
|
allow: "0",
|
||||||
|
deny: "0",
|
||||||
|
};
|
||||||
|
channel.permission_overwrites?.push(overwrite);
|
||||||
|
}
|
||||||
|
overwrite.allow = String(
|
||||||
|
(req.permission?.bitfield || 0n) &
|
||||||
|
(BigInt(body.allow) || BigInt("0")),
|
||||||
|
);
|
||||||
|
overwrite.deny = String(
|
||||||
|
(req.permission?.bitfield || 0n) &
|
||||||
|
(BigInt(body.deny) || BigInt("0")),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
channel.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "CHANNEL_UPDATE",
|
||||||
|
channel_id,
|
||||||
|
data: channel,
|
||||||
|
} as ChannelUpdateEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: check permission hierarchy
|
||||||
|
router.delete(
|
||||||
|
"/:overwrite_id",
|
||||||
|
route({ permission: "MANAGE_ROLES", responses: { 204: {}, 404: {} } }),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id, overwrite_id } = req.params;
|
||||||
|
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
if (!channel.guild_id) throw new HTTPError("Channel not found", 404);
|
||||||
|
|
||||||
|
channel.permission_overwrites = channel.permission_overwrites?.filter(
|
||||||
|
(x) => x.id === overwrite_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
channel.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "CHANNEL_UPDATE",
|
||||||
|
channel_id,
|
||||||
|
data: channel,
|
||||||
|
} as ChannelUpdateEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
189
src/api/routes/channels/#channel_id/pins.ts
Normal file
189
src/api/routes/channels/#channel_id/pins.ts
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
ChannelPinsUpdateEvent,
|
||||||
|
Config,
|
||||||
|
DiscordApiErrors,
|
||||||
|
emitEvent,
|
||||||
|
Message,
|
||||||
|
MessageCreateEvent,
|
||||||
|
MessageUpdateEvent,
|
||||||
|
User,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/:message_id",
|
||||||
|
route({
|
||||||
|
permission: "VIEW_CHANNEL",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
403: {},
|
||||||
|
404: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id, message_id } = req.params;
|
||||||
|
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// * in dm channels anyone can pin messages -> only check for guilds
|
||||||
|
if (message.guild_id) req.permission?.hasThrow("MANAGE_MESSAGES");
|
||||||
|
|
||||||
|
const pinned_count = await Message.count({
|
||||||
|
where: { channel: { id: channel_id }, pinned: true },
|
||||||
|
});
|
||||||
|
const { maxPins } = Config.get().limits.channel;
|
||||||
|
if (pinned_count >= maxPins)
|
||||||
|
throw DiscordApiErrors.MAXIMUM_PINS.withParams(maxPins);
|
||||||
|
|
||||||
|
message.pinned = true;
|
||||||
|
|
||||||
|
const author = await User.getPublicUser(req.user_id);
|
||||||
|
|
||||||
|
const systemPinMessage = Message.create({
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: 6,
|
||||||
|
guild_id: message.guild_id,
|
||||||
|
channel_id: message.channel_id,
|
||||||
|
author,
|
||||||
|
message_reference: {
|
||||||
|
message_id: message.id,
|
||||||
|
channel_id: message.channel_id,
|
||||||
|
guild_id: message.guild_id,
|
||||||
|
},
|
||||||
|
reactions: [],
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
sticker_items: [],
|
||||||
|
edited_timestamp: undefined,
|
||||||
|
mentions: [],
|
||||||
|
mention_channels: [],
|
||||||
|
mention_roles: [],
|
||||||
|
mention_everyone: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
message.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "MESSAGE_UPDATE",
|
||||||
|
channel_id,
|
||||||
|
data: message,
|
||||||
|
} as MessageUpdateEvent),
|
||||||
|
emitEvent({
|
||||||
|
event: "CHANNEL_PINS_UPDATE",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
channel_id,
|
||||||
|
guild_id: message.guild_id,
|
||||||
|
last_pin_timestamp: undefined,
|
||||||
|
},
|
||||||
|
} as ChannelPinsUpdateEvent),
|
||||||
|
systemPinMessage.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "MESSAGE_CREATE",
|
||||||
|
channel_id: message.channel_id,
|
||||||
|
data: systemPinMessage,
|
||||||
|
} as MessageCreateEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:message_id",
|
||||||
|
route({
|
||||||
|
permission: "VIEW_CHANNEL",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
403: {},
|
||||||
|
404: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id, message_id } = req.params;
|
||||||
|
|
||||||
|
const message = await Message.findOneOrFail({
|
||||||
|
where: { id: message_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message.guild_id) req.permission?.hasThrow("MANAGE_MESSAGES");
|
||||||
|
|
||||||
|
message.pinned = false;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
message.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "MESSAGE_UPDATE",
|
||||||
|
channel_id,
|
||||||
|
data: message,
|
||||||
|
} as MessageUpdateEvent),
|
||||||
|
emitEvent({
|
||||||
|
event: "CHANNEL_PINS_UPDATE",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
channel_id,
|
||||||
|
guild_id: message.guild_id,
|
||||||
|
last_pin_timestamp: undefined,
|
||||||
|
},
|
||||||
|
} as ChannelPinsUpdateEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: ["READ_MESSAGE_HISTORY"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIMessageArray",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
|
||||||
|
const pins = await Message.find({
|
||||||
|
where: { channel_id: channel_id, pinned: true },
|
||||||
|
relations: ["author"],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send(pins);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
125
src/api/routes/channels/#channel_id/purge.ts
Normal file
125
src/api/routes/channels/#channel_id/purge.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
Message,
|
||||||
|
MessageDeleteBulkEvent,
|
||||||
|
PurgeSchema,
|
||||||
|
emitEvent,
|
||||||
|
getPermission,
|
||||||
|
getRights,
|
||||||
|
isTextChannel,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { Between, FindManyOptions, FindOperator, Not } from "typeorm";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
/**
|
||||||
|
TODO: apply the delete bit by bit to prevent client and database stress
|
||||||
|
**/
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
/*body: "PurgeSchema",*/
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!channel.guild_id)
|
||||||
|
throw new HTTPError("Can't purge dm channels", 400);
|
||||||
|
isTextChannel(channel.type);
|
||||||
|
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
if (!rights.has("MANAGE_MESSAGES")) {
|
||||||
|
const permissions = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
channel.guild_id,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
permissions.hasThrow("MANAGE_MESSAGES");
|
||||||
|
permissions.hasThrow("MANAGE_CHANNELS");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { before, after } = req.body as PurgeSchema;
|
||||||
|
|
||||||
|
// TODO: send the deletion event bite-by-bite to prevent client stress
|
||||||
|
|
||||||
|
const query: FindManyOptions<Message> & {
|
||||||
|
where: { id?: FindOperator<string> };
|
||||||
|
} = {
|
||||||
|
order: { id: "ASC" },
|
||||||
|
// take: limit,
|
||||||
|
where: {
|
||||||
|
channel_id,
|
||||||
|
id: Between(after, before), // the right way around
|
||||||
|
author_id: rights.has("SELF_DELETE_MESSAGES")
|
||||||
|
? undefined
|
||||||
|
: Not(req.user_id),
|
||||||
|
// if you lack the right of self-deletion, you can't delete your own messages, even in purges
|
||||||
|
},
|
||||||
|
relations: [
|
||||||
|
"author",
|
||||||
|
"webhook",
|
||||||
|
"application",
|
||||||
|
"mentions",
|
||||||
|
"mention_roles",
|
||||||
|
"mention_channels",
|
||||||
|
"sticker_items",
|
||||||
|
"attachments",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = await Message.find(query);
|
||||||
|
|
||||||
|
if (messages.length == 0) {
|
||||||
|
res.sendStatus(304);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Message.delete(messages.map((x) => x.id));
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "MESSAGE_DELETE_BULK",
|
||||||
|
channel_id,
|
||||||
|
data: {
|
||||||
|
ids: messages.map((x) => x.id),
|
||||||
|
channel_id,
|
||||||
|
guild_id: channel.guild_id,
|
||||||
|
},
|
||||||
|
} as MessageDeleteBulkEvent);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
125
src/api/routes/channels/#channel_id/recipients.ts
Normal file
125
src/api/routes/channels/#channel_id/recipients.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
ChannelRecipientAddEvent,
|
||||||
|
ChannelType,
|
||||||
|
DiscordApiErrors,
|
||||||
|
DmChannelDTO,
|
||||||
|
emitEvent,
|
||||||
|
PublicUserProjection,
|
||||||
|
Recipient,
|
||||||
|
User,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/:user_id",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
201: {},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id, user_id } = req.params;
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
relations: ["recipients"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (channel.type !== ChannelType.GROUP_DM) {
|
||||||
|
const recipients = [
|
||||||
|
...(channel.recipients?.map((r) => r.user_id) || []),
|
||||||
|
user_id,
|
||||||
|
].unique();
|
||||||
|
|
||||||
|
const new_channel = await Channel.createDMChannel(
|
||||||
|
recipients,
|
||||||
|
req.user_id,
|
||||||
|
);
|
||||||
|
return res.status(201).json(new_channel);
|
||||||
|
} else {
|
||||||
|
if (channel.recipients?.map((r) => r.user_id).includes(user_id)) {
|
||||||
|
throw DiscordApiErrors.INVALID_RECIPIENT; //TODO is this the right error?
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.recipients?.push(
|
||||||
|
Recipient.create({ channel_id: channel_id, user_id: user_id }),
|
||||||
|
);
|
||||||
|
await channel.save();
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "CHANNEL_CREATE",
|
||||||
|
data: await DmChannelDTO.from(channel, [user_id]),
|
||||||
|
user_id: user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "CHANNEL_RECIPIENT_ADD",
|
||||||
|
data: {
|
||||||
|
channel_id: channel_id,
|
||||||
|
user: await User.findOneOrFail({
|
||||||
|
where: { id: user_id },
|
||||||
|
select: PublicUserProjection,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
channel_id: channel_id,
|
||||||
|
} as ChannelRecipientAddEvent);
|
||||||
|
return res.sendStatus(204);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:user_id",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
404: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id, user_id } = req.params;
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
relations: ["recipients"],
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
channel.type === ChannelType.GROUP_DM &&
|
||||||
|
(channel.owner_id === req.user_id || user_id === req.user_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw DiscordApiErrors.MISSING_PERMISSIONS;
|
||||||
|
|
||||||
|
if (!channel.recipients?.map((r) => r.user_id).includes(user_id)) {
|
||||||
|
throw DiscordApiErrors.INVALID_RECIPIENT; //TODO is this the right error?
|
||||||
|
}
|
||||||
|
|
||||||
|
await Channel.removeRecipientFromChannel(channel, user_id);
|
||||||
|
|
||||||
|
return res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
70
src/api/routes/channels/#channel_id/typing.ts
Normal file
70
src/api/routes/channels/#channel_id/typing.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Channel, emitEvent, Member, TypingStartEvent } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "SEND_MESSAGES",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
404: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
const user_id = req.user_id;
|
||||||
|
const timestamp = Date.nowSeconds();
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
const member = await Member.findOne({
|
||||||
|
where: { id: user_id, guild_id: channel.guild_id },
|
||||||
|
relations: ["roles", "user"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "TYPING_START",
|
||||||
|
channel_id: channel_id,
|
||||||
|
data: {
|
||||||
|
...(member
|
||||||
|
? {
|
||||||
|
member: {
|
||||||
|
...member,
|
||||||
|
roles: member?.roles?.map((x) => x.id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null),
|
||||||
|
channel_id,
|
||||||
|
timestamp,
|
||||||
|
user_id,
|
||||||
|
guild_id: channel.guild_id,
|
||||||
|
},
|
||||||
|
} as TypingStartEvent);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
138
src/api/routes/channels/#channel_id/webhooks.ts
Normal file
138
src/api/routes/channels/#channel_id/webhooks.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
Config,
|
||||||
|
DiscordApiErrors,
|
||||||
|
User,
|
||||||
|
Webhook,
|
||||||
|
WebhookCreateSchema,
|
||||||
|
WebhookType,
|
||||||
|
handleFile,
|
||||||
|
isTextChannel,
|
||||||
|
trimSpecial,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
description:
|
||||||
|
"Returns a list of channel webhook objects. Requires the MANAGE_WEBHOOKS permission.",
|
||||||
|
permission: "MANAGE_WEBHOOKS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIWebhookArray",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { channel_id } = req.params;
|
||||||
|
const webhooks = await Webhook.find({
|
||||||
|
where: { channel_id },
|
||||||
|
relations: [
|
||||||
|
"user",
|
||||||
|
"channel",
|
||||||
|
"source_channel",
|
||||||
|
"guild",
|
||||||
|
"source_guild",
|
||||||
|
"application",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const instanceUrl =
|
||||||
|
Config.get().api.endpointPublic || "http://localhost:3001";
|
||||||
|
return res.json(
|
||||||
|
webhooks.map((webhook) => ({
|
||||||
|
...webhook,
|
||||||
|
url:
|
||||||
|
instanceUrl +
|
||||||
|
"/webhooks/" +
|
||||||
|
webhook.id +
|
||||||
|
"/" +
|
||||||
|
webhook.token,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: use Image Data Type for avatar instead of String
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "WebhookCreateSchema",
|
||||||
|
permission: "MANAGE_WEBHOOKS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "WebhookCreateResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const channel_id = req.params.channel_id;
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
isTextChannel(channel.type);
|
||||||
|
if (!channel.guild_id) throw new HTTPError("Not a guild channel", 400);
|
||||||
|
|
||||||
|
const webhook_count = await Webhook.count({ where: { channel_id } });
|
||||||
|
const { maxWebhooks } = Config.get().limits.channel;
|
||||||
|
if (maxWebhooks && webhook_count > maxWebhooks)
|
||||||
|
throw DiscordApiErrors.MAXIMUM_WEBHOOKS.withParams(maxWebhooks);
|
||||||
|
|
||||||
|
let { avatar, name } = req.body as WebhookCreateSchema;
|
||||||
|
name = trimSpecial(name);
|
||||||
|
|
||||||
|
// TODO: move this
|
||||||
|
if (name === "clyde") throw new HTTPError("Invalid name", 400);
|
||||||
|
if (name === "Spacebar Ghost") throw new HTTPError("Invalid name", 400);
|
||||||
|
|
||||||
|
if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar);
|
||||||
|
|
||||||
|
const hook = await Webhook.create({
|
||||||
|
type: WebhookType.Incoming,
|
||||||
|
name,
|
||||||
|
avatar,
|
||||||
|
guild_id: channel.guild_id,
|
||||||
|
channel_id: channel.id,
|
||||||
|
user_id: req.user_id,
|
||||||
|
token: crypto.randomBytes(24).toString("base64url"),
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
const user = await User.getPublicUser(req.user_id);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
...hook,
|
||||||
|
user: user,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/", route({}), async (req: Request, res: Response) => {
|
||||||
|
// TODO:
|
||||||
|
// const { connection_name, connection_id } = req.params;
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
52
src/api/routes/connections/#connection_name/authorize.ts
Normal file
52
src/api/routes/connections/#connection_name/authorize.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { ConnectionStore, FieldErrors } from "../../../../util";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", route({}), async (req: Request, res: Response) => {
|
||||||
|
const { connection_name } = req.params;
|
||||||
|
const connection = ConnectionStore.connections.get(connection_name);
|
||||||
|
if (!connection)
|
||||||
|
throw FieldErrors({
|
||||||
|
provider_id: {
|
||||||
|
code: "BASE_TYPE_CHOICES",
|
||||||
|
message: req.t("common:field.BASE_TYPE_CHOICES", {
|
||||||
|
types: Array.from(ConnectionStore.connections.keys()).join(
|
||||||
|
", ",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection.settings.enabled)
|
||||||
|
throw FieldErrors({
|
||||||
|
provider_id: {
|
||||||
|
message: "This connection has been disabled server-side.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
url: await connection.getAuthorizationUrl(req.user_id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
71
src/api/routes/connections/#connection_name/callback.ts
Normal file
71
src/api/routes/connections/#connection_name/callback.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
ConnectionCallbackSchema,
|
||||||
|
ConnectionStore,
|
||||||
|
emitEvent,
|
||||||
|
FieldErrors,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({ requestBody: "ConnectionCallbackSchema" }),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { connection_name } = req.params;
|
||||||
|
const connection = ConnectionStore.connections.get(connection_name);
|
||||||
|
if (!connection)
|
||||||
|
throw FieldErrors({
|
||||||
|
provider_id: {
|
||||||
|
code: "BASE_TYPE_CHOICES",
|
||||||
|
message: req.t("common:field.BASE_TYPE_CHOICES", {
|
||||||
|
types: Array.from(
|
||||||
|
ConnectionStore.connections.keys(),
|
||||||
|
).join(", "),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection.settings.enabled)
|
||||||
|
throw FieldErrors({
|
||||||
|
provider_id: {
|
||||||
|
message: "This connection has been disabled server-side.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = req.body as ConnectionCallbackSchema;
|
||||||
|
const userId = connection.getUserId(body.state);
|
||||||
|
const connectedAccnt = await connection.handleCallback(body);
|
||||||
|
|
||||||
|
// whether we should emit a connections update event, only used when a connection doesnt already exist
|
||||||
|
if (connectedAccnt)
|
||||||
|
emitEvent({
|
||||||
|
event: "USER_CONNECTIONS_UPDATE",
|
||||||
|
data: { ...connectedAccnt, token_data: undefined },
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
45
src/api/routes/connections/index.ts
Normal file
45
src/api/routes/connections/index.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { ConnectionConfig } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIConnectionsConfiguration",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const config = ConnectionConfig.get();
|
||||||
|
|
||||||
|
Object.keys(config).forEach((key) => {
|
||||||
|
delete config[key].clientId;
|
||||||
|
delete config[key].clientSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(config);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
76
src/api/routes/discoverable-guilds.ts
Normal file
76
src/api/routes/discoverable-guilds.ts
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Config, Guild } from "@valkyrie/util";
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { Like } from "typeorm";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "DiscoverableGuildsResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { offset, limit, categories } = req.query;
|
||||||
|
const showAllGuilds = Config.get().guild.discovery.showAllGuilds;
|
||||||
|
const configLimit = Config.get().guild.discovery.limit;
|
||||||
|
let guilds;
|
||||||
|
if (categories == undefined) {
|
||||||
|
guilds = showAllGuilds
|
||||||
|
? await Guild.find({
|
||||||
|
take: Math.abs(Number(limit || configLimit)),
|
||||||
|
})
|
||||||
|
: await Guild.find({
|
||||||
|
where: { features: Like(`%DISCOVERABLE%`) },
|
||||||
|
take: Math.abs(Number(limit || configLimit)),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
guilds = showAllGuilds
|
||||||
|
? await Guild.find({
|
||||||
|
where: { primary_category_id: categories.toString() },
|
||||||
|
take: Math.abs(Number(limit || configLimit)),
|
||||||
|
})
|
||||||
|
: await Guild.find({
|
||||||
|
where: {
|
||||||
|
primary_category_id: categories.toString(),
|
||||||
|
features: Like("%DISCOVERABLE%"),
|
||||||
|
},
|
||||||
|
take: Math.abs(Number(limit || configLimit)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = guilds ? guilds.length : undefined;
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
total: total,
|
||||||
|
guilds: guilds,
|
||||||
|
offset: Number(offset || Config.get().guild.discovery.offset),
|
||||||
|
limit: Number(limit || configLimit),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
49
src/api/routes/discovery.ts
Normal file
49
src/api/routes/discovery.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Categories } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/categories",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIDiscoveryCategoryArray",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// TODO:
|
||||||
|
// Get locale instead
|
||||||
|
|
||||||
|
// const { locale, primary_only } = req.query;
|
||||||
|
const { primary_only } = req.query;
|
||||||
|
|
||||||
|
const out = primary_only
|
||||||
|
? await Categories.find({ where: { is_primary: true } })
|
||||||
|
: await Categories.find();
|
||||||
|
|
||||||
|
res.send(out);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
58
src/api/routes/download.ts
Normal file
58
src/api/routes/download.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { FieldErrors, Release } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
302: {},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { platform } = req.query;
|
||||||
|
|
||||||
|
if (!platform)
|
||||||
|
throw FieldErrors({
|
||||||
|
platform: {
|
||||||
|
code: "BASE_TYPE_REQUIRED",
|
||||||
|
message: req.t("common:field.BASE_TYPE_REQUIRED"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const release = await Release.findOneOrFail({
|
||||||
|
where: {
|
||||||
|
enabled: true,
|
||||||
|
platform: platform as string,
|
||||||
|
},
|
||||||
|
order: { pub_date: "DESC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect(release.url);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
29
src/api/routes/experiments.ts
Normal file
29
src/api/routes/experiments.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Response, Request } from "express";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", route({}), (req: Request, res: Response) => {
|
||||||
|
// TODO:
|
||||||
|
res.send({ fingerprint: "", assignments: [], guild_experiments: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
49
src/api/routes/gateway/bot.ts
Normal file
49
src/api/routes/gateway/bot.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Config } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GatewayBotResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(req: Request, res: Response) => {
|
||||||
|
const { endpointPublic } = Config.get().gateway;
|
||||||
|
res.json({
|
||||||
|
url: endpointPublic || process.env.GATEWAY || "ws://localhost:3001",
|
||||||
|
shards: 1,
|
||||||
|
session_start_limit: {
|
||||||
|
total: 1000,
|
||||||
|
remaining: 999,
|
||||||
|
reset_after: 14400000,
|
||||||
|
max_concurrency: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
42
src/api/routes/gateway/index.ts
Normal file
42
src/api/routes/gateway/index.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Config } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GatewayResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(req: Request, res: Response) => {
|
||||||
|
const { endpointPublic } = Config.get().gateway;
|
||||||
|
res.json({
|
||||||
|
url: endpointPublic || process.env.GATEWAY || "ws://localhost:3001",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
77
src/api/routes/gifs/search.ts
Normal file
77
src/api/routes/gifs/search.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { TenorMediaTypes, getGifApiKey, parseGifResult } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
import { ProxyAgent } from "proxy-agent";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
query: {
|
||||||
|
q: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Search query",
|
||||||
|
},
|
||||||
|
media_format: {
|
||||||
|
type: "string",
|
||||||
|
description: "Media format",
|
||||||
|
values: Object.keys(TenorMediaTypes).filter((key) =>
|
||||||
|
isNaN(Number(key)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: "string",
|
||||||
|
description: "Locale",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "TenorGifsResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// TODO: Custom providers
|
||||||
|
const { q, media_format, locale } = req.query;
|
||||||
|
|
||||||
|
const apiKey = getGifApiKey();
|
||||||
|
|
||||||
|
const agent = new ProxyAgent();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://g.tenor.com/v1/search?q=${q}&media_format=${media_format}&locale=${locale}&key=${apiKey}`,
|
||||||
|
{
|
||||||
|
agent,
|
||||||
|
method: "get",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { results } = await response.json();
|
||||||
|
|
||||||
|
res.json(results.map(parseGifResult)).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
72
src/api/routes/gifs/trending-gifs.ts
Normal file
72
src/api/routes/gifs/trending-gifs.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { TenorMediaTypes, getGifApiKey, parseGifResult } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
import { ProxyAgent } from "proxy-agent";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
query: {
|
||||||
|
media_format: {
|
||||||
|
type: "string",
|
||||||
|
description: "Media format",
|
||||||
|
values: Object.keys(TenorMediaTypes).filter((key) =>
|
||||||
|
isNaN(Number(key)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: "string",
|
||||||
|
description: "Locale",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "TenorGifsResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// TODO: Custom providers
|
||||||
|
const { media_format, locale } = req.query;
|
||||||
|
|
||||||
|
const apiKey = getGifApiKey();
|
||||||
|
|
||||||
|
const agent = new ProxyAgent();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://g.tenor.com/v1/trending?media_format=${media_format}&locale=${locale}&key=${apiKey}`,
|
||||||
|
{
|
||||||
|
agent,
|
||||||
|
method: "get",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { results } = await response.json();
|
||||||
|
|
||||||
|
res.json(results.map(parseGifResult)).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
91
src/api/routes/gifs/trending.ts
Normal file
91
src/api/routes/gifs/trending.ts
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
TenorCategoriesResults,
|
||||||
|
TenorTrendingResults,
|
||||||
|
getGifApiKey,
|
||||||
|
parseGifResult,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
import { ProxyAgent } from "proxy-agent";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
query: {
|
||||||
|
locale: {
|
||||||
|
type: "string",
|
||||||
|
description: "Locale",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "TenorTrendingResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// TODO: Custom providers
|
||||||
|
// TODO: return gifs as mp4
|
||||||
|
// const { media_format, locale } = req.query;
|
||||||
|
const { locale } = req.query;
|
||||||
|
|
||||||
|
const apiKey = getGifApiKey();
|
||||||
|
|
||||||
|
const agent = new ProxyAgent();
|
||||||
|
|
||||||
|
const [responseSource, trendGifSource] = await Promise.all([
|
||||||
|
fetch(
|
||||||
|
`https://g.tenor.com/v1/categories?locale=${locale}&key=${apiKey}`,
|
||||||
|
{
|
||||||
|
agent,
|
||||||
|
method: "get",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`https://g.tenor.com/v1/trending?locale=${locale}&key=${apiKey}`,
|
||||||
|
{
|
||||||
|
agent,
|
||||||
|
method: "get",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { tags } =
|
||||||
|
(await responseSource.json()) as TenorCategoriesResults;
|
||||||
|
const { results } =
|
||||||
|
(await trendGifSource.json()) as TenorTrendingResults;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
categories: tags.map((x) => ({
|
||||||
|
name: x.searchterm,
|
||||||
|
src: x.image,
|
||||||
|
})),
|
||||||
|
gifs: [parseGifResult(results[0])],
|
||||||
|
}).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
59
src/api/routes/guild-recommendations.ts
Normal file
59
src/api/routes/guild-recommendations.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Config, Guild } from "@valkyrie/util";
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { Like } from "typeorm";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildRecommendationsResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// const { limit, personalization_disabled } = req.query;
|
||||||
|
const { limit } = req.query;
|
||||||
|
const showAllGuilds = Config.get().guild.discovery.showAllGuilds;
|
||||||
|
|
||||||
|
const genLoadId = (size: number) =>
|
||||||
|
[...Array(size)]
|
||||||
|
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const guilds = showAllGuilds
|
||||||
|
? await Guild.find({ take: Math.abs(Number(limit || 24)) })
|
||||||
|
: await Guild.find({
|
||||||
|
where: { features: Like("%DISCOVERABLE%") },
|
||||||
|
take: Math.abs(Number(limit || 24)),
|
||||||
|
});
|
||||||
|
res.send({
|
||||||
|
recommended_guilds: guilds,
|
||||||
|
load_id: `server_recs/${genLoadId(32)}`,
|
||||||
|
}).status(200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
35
src/api/routes/guilds/#guild_id/audit-logs.ts
Normal file
35
src/api/routes/guilds/#guild_id/audit-logs.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Response, Request } from "express";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
//TODO: implement audit logs
|
||||||
|
router.get("/", route({}), async (req: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
audit_log_entries: [],
|
||||||
|
users: [],
|
||||||
|
integrations: [],
|
||||||
|
webhooks: [],
|
||||||
|
guild_scheduled_events: [],
|
||||||
|
threads: [],
|
||||||
|
application_commands: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
export default router;
|
227
src/api/routes/guilds/#guild_id/bans.ts
Normal file
227
src/api/routes/guilds/#guild_id/bans.ts
Normal file
|
@ -0,0 +1,227 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getIpAdress, route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
BanRegistrySchema,
|
||||||
|
DiscordApiErrors,
|
||||||
|
GuildBanAddEvent,
|
||||||
|
GuildBanRemoveEvent,
|
||||||
|
Member,
|
||||||
|
User,
|
||||||
|
emitEvent,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
/* TODO: Deleting the secrets is just a temporary go-around. Views should be implemented for both safety and better handling. */
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "BAN_MEMBERS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildBansResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const bans = await Ban.find({ where: { guild_id: guild_id } });
|
||||||
|
const promisesToAwait: object[] = [];
|
||||||
|
const bansObj: object[] = [];
|
||||||
|
|
||||||
|
bans.filter((ban) => ban.user_id !== ban.executor_id); // pretend self-bans don't exist to prevent victim chasing
|
||||||
|
|
||||||
|
bans.forEach((ban) => {
|
||||||
|
promisesToAwait.push(User.getPublicUser(ban.user_id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const bannedUsers: object[] = await Promise.all(promisesToAwait);
|
||||||
|
|
||||||
|
bans.forEach((ban, index) => {
|
||||||
|
const user = bannedUsers[index] as User;
|
||||||
|
bansObj.push({
|
||||||
|
reason: ban.reason,
|
||||||
|
user: {
|
||||||
|
username: user.username,
|
||||||
|
discriminator: user.discriminator,
|
||||||
|
id: user.id,
|
||||||
|
avatar: user.avatar,
|
||||||
|
public_flags: user.public_flags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(bansObj);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:user_id",
|
||||||
|
route({
|
||||||
|
permission: "BAN_MEMBERS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "BanModeratorSchema",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, user_id } = req.params;
|
||||||
|
|
||||||
|
const ban = (await Ban.findOneOrFail({
|
||||||
|
where: { guild_id: guild_id, user_id: user_id },
|
||||||
|
})) as BanRegistrySchema;
|
||||||
|
|
||||||
|
if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN;
|
||||||
|
// pretend self-bans don't exist to prevent victim chasing
|
||||||
|
|
||||||
|
const banInfo = {
|
||||||
|
user: await User.getPublicUser(ban.user_id),
|
||||||
|
reason: ban.reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.json(banInfo);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/:user_id",
|
||||||
|
route({
|
||||||
|
requestBody: "BanCreateSchema",
|
||||||
|
permission: "BAN_MEMBERS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Ban",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const banned_user_id = req.params.user_id;
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.user_id === banned_user_id &&
|
||||||
|
banned_user_id === req.permission?.cache.guild?.owner_id
|
||||||
|
)
|
||||||
|
throw new HTTPError(
|
||||||
|
"You are the guild owner, hence can't ban yourself",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (req.permission?.cache.guild?.owner_id === banned_user_id)
|
||||||
|
throw new HTTPError("You can't ban the owner", 400);
|
||||||
|
|
||||||
|
const existingBan = await Ban.findOne({
|
||||||
|
where: { guild_id: guild_id, user_id: banned_user_id },
|
||||||
|
});
|
||||||
|
// Bans on already banned users are silently ignored
|
||||||
|
if (existingBan) return res.status(204).send();
|
||||||
|
|
||||||
|
const banned_user = await User.getPublicUser(banned_user_id);
|
||||||
|
|
||||||
|
const ban = Ban.create({
|
||||||
|
user_id: banned_user_id,
|
||||||
|
guild_id: guild_id,
|
||||||
|
ip: getIpAdress(req),
|
||||||
|
executor_id: req.user_id,
|
||||||
|
reason: req.body.reason, // || otherwise empty
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
Member.removeFromGuild(banned_user_id, guild_id),
|
||||||
|
ban.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "GUILD_BAN_ADD",
|
||||||
|
data: {
|
||||||
|
guild_id: guild_id,
|
||||||
|
user: banned_user,
|
||||||
|
},
|
||||||
|
guild_id: guild_id,
|
||||||
|
} as GuildBanAddEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:user_id",
|
||||||
|
route({
|
||||||
|
permission: "BAN_MEMBERS",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, user_id } = req.params;
|
||||||
|
|
||||||
|
await Ban.findOneOrFail({
|
||||||
|
where: { guild_id: guild_id, user_id: user_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const banned_user = await User.getPublicUser(user_id);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
Ban.delete({
|
||||||
|
user_id: user_id,
|
||||||
|
guild_id,
|
||||||
|
}),
|
||||||
|
|
||||||
|
emitEvent({
|
||||||
|
event: "GUILD_BAN_REMOVE",
|
||||||
|
data: {
|
||||||
|
guild_id,
|
||||||
|
user: banned_user,
|
||||||
|
},
|
||||||
|
guild_id,
|
||||||
|
} as GuildBanRemoveEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
132
src/api/routes/guilds/#guild_id/bulk-ban.ts
Normal file
132
src/api/routes/guilds/#guild_id/bulk-ban.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getIpAdress, route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
DiscordApiErrors,
|
||||||
|
GuildBanAddEvent,
|
||||||
|
Member,
|
||||||
|
User,
|
||||||
|
emitEvent,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { Config } from "@valkyrie/util";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "BulkBanSchema",
|
||||||
|
permission: ["BAN_MEMBERS", "MANAGE_GUILD"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Ban",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const userIds: Array<string> = req.body.user_ids;
|
||||||
|
if (!userIds) throw new HTTPError("The user_ids array is missing", 400);
|
||||||
|
|
||||||
|
if (userIds.length > Config.get().limits.guild.maxBulkBanUsers)
|
||||||
|
throw new HTTPError(
|
||||||
|
"The user_ids array must be between 1 and 200 in length",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
|
||||||
|
const banned_users = [];
|
||||||
|
const failed_users = [];
|
||||||
|
for await (const banned_user_id of userIds) {
|
||||||
|
if (
|
||||||
|
req.user_id === banned_user_id &&
|
||||||
|
banned_user_id === req.permission?.cache.guild?.owner_id
|
||||||
|
) {
|
||||||
|
failed_users.push(banned_user_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.permission?.cache.guild?.owner_id === banned_user_id) {
|
||||||
|
failed_users.push(banned_user_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingBan = await Ban.findOne({
|
||||||
|
where: { guild_id: guild_id, user_id: banned_user_id },
|
||||||
|
});
|
||||||
|
if (existingBan) {
|
||||||
|
failed_users.push(banned_user_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let banned_user;
|
||||||
|
try {
|
||||||
|
banned_user = await User.getPublicUser(banned_user_id);
|
||||||
|
} catch {
|
||||||
|
failed_users.push(banned_user_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ban = Ban.create({
|
||||||
|
user_id: banned_user_id,
|
||||||
|
guild_id: guild_id,
|
||||||
|
ip: getIpAdress(req),
|
||||||
|
executor_id: req.user_id,
|
||||||
|
reason: req.body.reason, // || otherwise empty
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
Member.removeFromGuild(banned_user_id, guild_id),
|
||||||
|
ban.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "GUILD_BAN_ADD",
|
||||||
|
data: {
|
||||||
|
guild_id: guild_id,
|
||||||
|
user: banned_user,
|
||||||
|
},
|
||||||
|
guild_id: guild_id,
|
||||||
|
} as GuildBanAddEvent),
|
||||||
|
]);
|
||||||
|
banned_users.push(banned_user_id);
|
||||||
|
} catch {
|
||||||
|
failed_users.push(banned_user_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (banned_users.length === 0 && failed_users.length > 0)
|
||||||
|
throw DiscordApiErrors.BULK_BAN_FAILED;
|
||||||
|
return res.json({
|
||||||
|
banned_users: banned_users,
|
||||||
|
failed_users: failed_users,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
185
src/api/routes/guilds/#guild_id/channels.ts
Normal file
185
src/api/routes/guilds/#guild_id/channels.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
ChannelModifySchema,
|
||||||
|
ChannelReorderSchema,
|
||||||
|
ChannelUpdateEvent,
|
||||||
|
Guild,
|
||||||
|
emitEvent,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
body: "APIChannelArray",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const channels = await Channel.find({ where: { guild_id } });
|
||||||
|
|
||||||
|
for await (const channel of channels) {
|
||||||
|
channel.position = await Channel.calculatePosition(
|
||||||
|
channel.id,
|
||||||
|
guild_id,
|
||||||
|
channel.guild,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
channels.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
res.json(channels);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "ChannelModifySchema",
|
||||||
|
permission: "MANAGE_CHANNELS",
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
body: "Channel",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// creates a new guild channel https://discord.com/developers/docs/resources/guild#create-guild-channel
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const body = req.body as ChannelModifySchema;
|
||||||
|
|
||||||
|
const channel = await Channel.createChannel(
|
||||||
|
{ ...body, guild_id },
|
||||||
|
req.user_id,
|
||||||
|
);
|
||||||
|
channel.position = await Channel.calculatePosition(
|
||||||
|
channel.id,
|
||||||
|
guild_id,
|
||||||
|
channel.guild,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(channel);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "ChannelReorderSchema",
|
||||||
|
permission: "MANAGE_CHANNELS",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// changes guild channel position
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const body = req.body as ChannelReorderSchema;
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { id: guild_id },
|
||||||
|
select: { channel_ordering: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The channels not listed for this query
|
||||||
|
const notMentioned = guild.channel_ordering.filter(
|
||||||
|
(x) => !body.find((c) => c.id == x),
|
||||||
|
);
|
||||||
|
|
||||||
|
const withParents = body.filter((x) => x.parent_id != undefined);
|
||||||
|
const withPositions = body.filter((x) => x.position != undefined);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
withPositions.map(async (opt) => {
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { id: opt.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
channel.position = opt.position as number;
|
||||||
|
notMentioned.splice(opt.position as number, 0, channel.id);
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "CHANNEL_UPDATE",
|
||||||
|
data: channel,
|
||||||
|
channel_id: channel.id,
|
||||||
|
guild_id,
|
||||||
|
} as ChannelUpdateEvent);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// have to do the parents after the positions
|
||||||
|
await Promise.all(
|
||||||
|
withParents.map(async (opt) => {
|
||||||
|
const [channel, parent] = await Promise.all([
|
||||||
|
Channel.findOneOrFail({
|
||||||
|
where: { id: opt.id },
|
||||||
|
}),
|
||||||
|
Channel.findOneOrFail({
|
||||||
|
where: { id: opt.parent_id as string },
|
||||||
|
select: { permission_overwrites: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (opt.lock_permissions)
|
||||||
|
await Channel.update(
|
||||||
|
{ id: channel.id },
|
||||||
|
{ permission_overwrites: parent.permission_overwrites },
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentPos = notMentioned.indexOf(parent.id);
|
||||||
|
notMentioned.splice(parentPos + 1, 0, channel.id);
|
||||||
|
channel.position = (parentPos + 1) as number;
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "CHANNEL_UPDATE",
|
||||||
|
data: channel,
|
||||||
|
channel_id: channel.id,
|
||||||
|
guild_id,
|
||||||
|
} as ChannelUpdateEvent);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Guild.update(
|
||||||
|
{ id: guild_id },
|
||||||
|
{ channel_ordering: notMentioned },
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
66
src/api/routes/guilds/#guild_id/delete.ts
Normal file
66
src/api/routes/guilds/#guild_id/delete.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Guild, GuildDeleteEvent, emitEvent } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// discord prefixes this route with /delete instead of using the delete method
|
||||||
|
// docs are wrong https://discord.com/developers/docs/resources/guild#delete-guild
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
401: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { id: guild_id },
|
||||||
|
select: ["owner_id"],
|
||||||
|
});
|
||||||
|
if (guild.owner_id !== req.user_id)
|
||||||
|
throw new HTTPError("You are not the owner of this guild", 401);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
Guild.delete({ id: guild_id }), // this will also delete all guild related data
|
||||||
|
emitEvent({
|
||||||
|
event: "GUILD_DELETE",
|
||||||
|
data: {
|
||||||
|
id: guild_id,
|
||||||
|
},
|
||||||
|
guild_id: guild_id,
|
||||||
|
} as GuildDeleteEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
65
src/api/routes/guilds/#guild_id/discovery-requirements.ts
Normal file
65
src/api/routes/guilds/#guild_id/discovery-requirements.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildDiscoveryRequirementsResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
// TODO:
|
||||||
|
// Load from database
|
||||||
|
// Admin control, but for now it allows anyone to be discoverable
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
guild_id: guild_id,
|
||||||
|
safe_environment: true,
|
||||||
|
healthy: true,
|
||||||
|
health_score_pending: false,
|
||||||
|
size: true,
|
||||||
|
nsfw_properties: {},
|
||||||
|
protected: true,
|
||||||
|
sufficient: true,
|
||||||
|
sufficient_without_grace_period: true,
|
||||||
|
valid_rules_channel: true,
|
||||||
|
retention_healthy: true,
|
||||||
|
engagement_healthy: true,
|
||||||
|
age: true,
|
||||||
|
minimum_age: 0,
|
||||||
|
health_score: {
|
||||||
|
avg_nonnew_participators: 0,
|
||||||
|
avg_nonnew_communicators: 0,
|
||||||
|
num_intentful_joiners: 0,
|
||||||
|
perc_ret_w1_intentful: 0,
|
||||||
|
},
|
||||||
|
minimum_size: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
226
src/api/routes/guilds/#guild_id/emojis.ts
Normal file
226
src/api/routes/guilds/#guild_id/emojis.ts
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
DiscordApiErrors,
|
||||||
|
Emoji,
|
||||||
|
EmojiCreateSchema,
|
||||||
|
EmojiModifySchema,
|
||||||
|
GuildEmojisUpdateEvent,
|
||||||
|
Member,
|
||||||
|
Snowflake,
|
||||||
|
User,
|
||||||
|
emitEvent,
|
||||||
|
handleFile,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIEmojiArray",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
|
||||||
|
const emojis = await Emoji.find({
|
||||||
|
where: { guild_id: guild_id },
|
||||||
|
relations: ["user"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(emojis);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:emoji_id",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Emoji",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, emoji_id } = req.params;
|
||||||
|
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
|
||||||
|
const emoji = await Emoji.findOneOrFail({
|
||||||
|
where: { guild_id: guild_id, id: emoji_id },
|
||||||
|
relations: ["user"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(emoji);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "EmojiCreateSchema",
|
||||||
|
permission: "MANAGE_EMOJIS_AND_STICKERS",
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
body: "Emoji",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const body = req.body as EmojiCreateSchema;
|
||||||
|
|
||||||
|
const id = Snowflake.generate();
|
||||||
|
const emoji_count = await Emoji.count({
|
||||||
|
where: { guild_id: guild_id },
|
||||||
|
});
|
||||||
|
const { maxEmojis } = Config.get().limits.guild;
|
||||||
|
|
||||||
|
if (emoji_count >= maxEmojis)
|
||||||
|
throw DiscordApiErrors.MAXIMUM_NUMBER_OF_EMOJIS_REACHED.withParams(
|
||||||
|
maxEmojis,
|
||||||
|
);
|
||||||
|
if (body.require_colons == null) body.require_colons = true;
|
||||||
|
|
||||||
|
const user = await User.findOneOrFail({ where: { id: req.user_id } });
|
||||||
|
body.image = (await handleFile(`/emojis/${id}`, body.image)) as string;
|
||||||
|
|
||||||
|
const mimeType = body.image.split(":")[1].split(";")[0];
|
||||||
|
const emoji = await Emoji.create({
|
||||||
|
id: id,
|
||||||
|
guild_id: guild_id,
|
||||||
|
...body,
|
||||||
|
require_colons: body.require_colons ?? undefined, // schema allows nulls, db does not
|
||||||
|
user: user,
|
||||||
|
managed: false,
|
||||||
|
animated:
|
||||||
|
mimeType == "image/gif" ||
|
||||||
|
mimeType == "image/apng" ||
|
||||||
|
mimeType == "video/webm",
|
||||||
|
available: true,
|
||||||
|
roles: [],
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "GUILD_EMOJIS_UPDATE",
|
||||||
|
guild_id: guild_id,
|
||||||
|
data: {
|
||||||
|
guild_id: guild_id,
|
||||||
|
emojis: await Emoji.find({ where: { guild_id: guild_id } }),
|
||||||
|
},
|
||||||
|
} as GuildEmojisUpdateEvent);
|
||||||
|
|
||||||
|
return res.status(201).json(emoji);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/:emoji_id",
|
||||||
|
route({
|
||||||
|
requestBody: "EmojiModifySchema",
|
||||||
|
permission: "MANAGE_EMOJIS_AND_STICKERS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Emoji",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { emoji_id, guild_id } = req.params;
|
||||||
|
const body = req.body as EmojiModifySchema;
|
||||||
|
|
||||||
|
const emoji = await Emoji.create({
|
||||||
|
...body,
|
||||||
|
id: emoji_id,
|
||||||
|
guild_id: guild_id,
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "GUILD_EMOJIS_UPDATE",
|
||||||
|
guild_id: guild_id,
|
||||||
|
data: {
|
||||||
|
guild_id: guild_id,
|
||||||
|
emojis: await Emoji.find({ where: { guild_id: guild_id } }),
|
||||||
|
},
|
||||||
|
} as GuildEmojisUpdateEvent);
|
||||||
|
|
||||||
|
return res.json(emoji);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:emoji_id",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_EMOJIS_AND_STICKERS",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { emoji_id, guild_id } = req.params;
|
||||||
|
|
||||||
|
await Emoji.delete({
|
||||||
|
id: emoji_id,
|
||||||
|
guild_id: guild_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitEvent({
|
||||||
|
event: "GUILD_EMOJIS_UPDATE",
|
||||||
|
guild_id: guild_id,
|
||||||
|
data: {
|
||||||
|
guild_id: guild_id,
|
||||||
|
emojis: await Emoji.find({ where: { guild_id: guild_id } }),
|
||||||
|
},
|
||||||
|
} as GuildEmojisUpdateEvent);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
248
src/api/routes/guilds/#guild_id/index.ts
Normal file
248
src/api/routes/guilds/#guild_id/index.ts
Normal file
|
@ -0,0 +1,248 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
DiscordApiErrors,
|
||||||
|
Guild,
|
||||||
|
GuildUpdateEvent,
|
||||||
|
GuildUpdateSchema,
|
||||||
|
Member,
|
||||||
|
Permissions,
|
||||||
|
SpacebarApiErrors,
|
||||||
|
emitEvent,
|
||||||
|
getPermission,
|
||||||
|
getRights,
|
||||||
|
handleFile,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
body: "APIGuildWithJoinedAt",
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const [guild, member] = await Promise.all([
|
||||||
|
Guild.findOneOrFail({ where: { id: guild_id } }),
|
||||||
|
Member.findOne({ where: { guild_id: guild_id, id: req.user_id } }),
|
||||||
|
]);
|
||||||
|
if (!member)
|
||||||
|
throw new HTTPError(
|
||||||
|
"You are not a member of the guild you are trying to access",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
...guild,
|
||||||
|
joined_at: member?.joined_at,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "GuildUpdateSchema",
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildCreateResponse",
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as GuildUpdateSchema;
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
const permission = await getPermission(req.user_id, guild_id);
|
||||||
|
|
||||||
|
if (!rights.has("MANAGE_GUILDS") && !permission.has("MANAGE_GUILD"))
|
||||||
|
throw DiscordApiErrors.MISSING_PERMISSIONS.withParams(
|
||||||
|
"MANAGE_GUILDS",
|
||||||
|
);
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { id: guild_id },
|
||||||
|
relations: ["emojis", "roles", "stickers"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: guild update check image
|
||||||
|
|
||||||
|
if (body.icon && body.icon != guild.icon)
|
||||||
|
body.icon = await handleFile(`/icons/${guild_id}`, body.icon);
|
||||||
|
|
||||||
|
if (body.banner && body.banner !== guild.banner)
|
||||||
|
body.banner = await handleFile(`/banners/${guild_id}`, body.banner);
|
||||||
|
|
||||||
|
if (body.splash && body.splash !== guild.splash)
|
||||||
|
body.splash = await handleFile(
|
||||||
|
`/splashes/${guild_id}`,
|
||||||
|
body.splash,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
body.discovery_splash &&
|
||||||
|
body.discovery_splash !== guild.discovery_splash
|
||||||
|
)
|
||||||
|
body.discovery_splash = await handleFile(
|
||||||
|
`/discovery-splashes/${guild_id}`,
|
||||||
|
body.discovery_splash,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (body.features) {
|
||||||
|
const diff = guild.features
|
||||||
|
.filter((x) => !body.features?.includes(x))
|
||||||
|
.concat(
|
||||||
|
body.features.filter((x) => !guild.features.includes(x)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO move these
|
||||||
|
const MUTABLE_FEATURES = [
|
||||||
|
"COMMUNITY",
|
||||||
|
"INVITES_DISABLED",
|
||||||
|
"DISCOVERABLE",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const feature of diff) {
|
||||||
|
if (MUTABLE_FEATURES.includes(feature)) continue;
|
||||||
|
|
||||||
|
throw SpacebarApiErrors.FEATURE_IS_IMMUTABLE.withParams(
|
||||||
|
feature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// for some reason, they don't update in the assign.
|
||||||
|
guild.features = body.features;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: check if body ids are valid
|
||||||
|
guild.assign(body);
|
||||||
|
|
||||||
|
if (body.public_updates_channel_id == "1") {
|
||||||
|
// create an updates channel for them
|
||||||
|
const channel = await Channel.createChannel(
|
||||||
|
{
|
||||||
|
name: "moderator-only",
|
||||||
|
guild_id: guild.id,
|
||||||
|
position: 0,
|
||||||
|
type: 0,
|
||||||
|
permission_overwrites: [
|
||||||
|
// remove SEND_MESSAGES from @everyone
|
||||||
|
{
|
||||||
|
id: guild.id,
|
||||||
|
allow: "0",
|
||||||
|
deny: Permissions.FLAGS.VIEW_CHANNEL.toString(),
|
||||||
|
type: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ skipPermissionCheck: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
await Guild.insertChannelInOrder(guild.id, channel.id, 0, guild);
|
||||||
|
|
||||||
|
guild.public_updates_channel_id = channel.id;
|
||||||
|
} else if (body.public_updates_channel_id != undefined) {
|
||||||
|
// ensure channel exists in this guild
|
||||||
|
await Channel.findOneOrFail({
|
||||||
|
where: { guild_id, id: body.public_updates_channel_id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.rules_channel_id == "1") {
|
||||||
|
// create a rules for them
|
||||||
|
const channel = await Channel.createChannel(
|
||||||
|
{
|
||||||
|
name: "rules",
|
||||||
|
guild_id: guild.id,
|
||||||
|
position: 0,
|
||||||
|
type: 0,
|
||||||
|
permission_overwrites: [
|
||||||
|
// remove SEND_MESSAGES from @everyone
|
||||||
|
{
|
||||||
|
id: guild.id,
|
||||||
|
allow: "0",
|
||||||
|
deny: Permissions.FLAGS.SEND_MESSAGES.toString(),
|
||||||
|
type: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ skipPermissionCheck: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
await Guild.insertChannelInOrder(guild.id, channel.id, 0, guild);
|
||||||
|
|
||||||
|
guild.rules_channel_id = channel.id;
|
||||||
|
} else if (body.rules_channel_id != undefined) {
|
||||||
|
// ensure channel exists in this guild
|
||||||
|
await Channel.findOneOrFail({
|
||||||
|
where: { guild_id, id: body.rules_channel_id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = guild.toJSON();
|
||||||
|
// TODO: guild hashes
|
||||||
|
// TODO: fix vanity_url_code, template_id
|
||||||
|
// delete data.vanity_url_code;
|
||||||
|
delete data.template_id;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
guild.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "GUILD_UPDATE",
|
||||||
|
data,
|
||||||
|
guild_id,
|
||||||
|
} as GuildUpdateEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json(data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
27
src/api/routes/guilds/#guild_id/integrations.ts
Normal file
27
src/api/routes/guilds/#guild_id/integrations.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Response, Request } from "express";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
//TODO: implement integrations list
|
||||||
|
router.get("/", route({}), async (req: Request, res: Response) => {
|
||||||
|
res.json([]);
|
||||||
|
});
|
||||||
|
export default router;
|
47
src/api/routes/guilds/#guild_id/invites.ts
Normal file
47
src/api/routes/guilds/#guild_id/invites.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Invite, PublicInviteRelation } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIInviteArray",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const invites = await Invite.find({
|
||||||
|
where: { guild_id },
|
||||||
|
relations: PublicInviteRelation,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(invites);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
42
src/api/routes/guilds/#guild_id/member-verification.ts
Normal file
42
src/api/routes/guilds/#guild_id/member-verification.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// TODO: member verification
|
||||||
|
|
||||||
|
res.status(404).json({
|
||||||
|
message: "Unknown Guild Member Verification Form",
|
||||||
|
code: 10068,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
251
src/api/routes/guilds/#guild_id/members/#member_id/index.ts
Normal file
251
src/api/routes/guilds/#guild_id/members/#member_id/index.ts
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
DiscordApiErrors,
|
||||||
|
emitEvent,
|
||||||
|
Emoji,
|
||||||
|
getPermission,
|
||||||
|
getRights,
|
||||||
|
Guild,
|
||||||
|
GuildMemberUpdateEvent,
|
||||||
|
handleFile,
|
||||||
|
Member,
|
||||||
|
MemberChangeSchema,
|
||||||
|
PublicMemberProjection,
|
||||||
|
PublicUserProjection,
|
||||||
|
Role,
|
||||||
|
Sticker,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIPublicMember",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, member_id } = req.params;
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
|
||||||
|
const member = await Member.findOneOrFail({
|
||||||
|
where: { id: member_id, guild_id },
|
||||||
|
relations: ["roles", "user"],
|
||||||
|
select: {
|
||||||
|
index: true,
|
||||||
|
// only grab public member props
|
||||||
|
...Object.fromEntries(
|
||||||
|
PublicMemberProjection.map((x) => [x, true]),
|
||||||
|
),
|
||||||
|
// and public user props
|
||||||
|
user: Object.fromEntries(
|
||||||
|
PublicUserProjection.map((x) => [x, true]),
|
||||||
|
),
|
||||||
|
roles: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
...member.toPublicMember(),
|
||||||
|
user: member.user.toPublicUser(),
|
||||||
|
roles: member.roles.map((x) => x.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "MemberChangeSchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Member",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const member_id =
|
||||||
|
req.params.member_id === "@me" ? req.user_id : req.params.member_id;
|
||||||
|
const body = req.body as MemberChangeSchema;
|
||||||
|
|
||||||
|
const member = await Member.findOneOrFail({
|
||||||
|
where: { id: member_id, guild_id },
|
||||||
|
relations: ["roles", "user"],
|
||||||
|
});
|
||||||
|
const permission = await getPermission(req.user_id, guild_id);
|
||||||
|
|
||||||
|
if ("nick" in body) {
|
||||||
|
permission.hasThrow("MANAGE_NICKNAMES");
|
||||||
|
|
||||||
|
if (!body.nick) {
|
||||||
|
delete body.nick;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore shut up
|
||||||
|
member.nick = null; // remove the nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
("bio" in body || "avatar" in body) &&
|
||||||
|
req.params.member_id != "@me"
|
||||||
|
) {
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
rights.hasThrow("MANAGE_USERS");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.avatar)
|
||||||
|
body.avatar = await handleFile(
|
||||||
|
`/guilds/${guild_id}/users/${member_id}/avatars`,
|
||||||
|
body.avatar as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
member.assign(body);
|
||||||
|
|
||||||
|
// must do this after the assign because the body roles array
|
||||||
|
// is string[] not Role[]
|
||||||
|
if ("roles" in body) {
|
||||||
|
permission.hasThrow("MANAGE_ROLES");
|
||||||
|
|
||||||
|
body.roles = body.roles || [];
|
||||||
|
body.roles.filter((x) => !!x);
|
||||||
|
|
||||||
|
if (body.roles.indexOf(guild_id) === -1) body.roles.push(guild_id);
|
||||||
|
// foreign key constraint will fail if role doesn't exist
|
||||||
|
member.roles = body.roles.map((x) => Role.create({ id: x }));
|
||||||
|
}
|
||||||
|
|
||||||
|
await member.save();
|
||||||
|
|
||||||
|
member.roles = member.roles.filter((x) => x.id !== guild_id);
|
||||||
|
|
||||||
|
// do not use promise.all as we have to first write to db before emitting the event to catch errors
|
||||||
|
await emitEvent({
|
||||||
|
event: "GUILD_MEMBER_UPDATE",
|
||||||
|
guild_id,
|
||||||
|
data: { ...member, roles: member.roles.map((x) => x.id) },
|
||||||
|
} as GuildMemberUpdateEvent);
|
||||||
|
|
||||||
|
res.json(member);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "MemberJoinGuildResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// TODO: Lurker mode
|
||||||
|
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
let { member_id } = req.params;
|
||||||
|
if (member_id === "@me") {
|
||||||
|
member_id = req.user_id;
|
||||||
|
rights.hasThrow("JOIN_GUILDS");
|
||||||
|
} else {
|
||||||
|
// TODO: check oauth2 scope
|
||||||
|
|
||||||
|
throw DiscordApiErrors.MISSING_REQUIRED_OAUTH2_SCOPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { id: guild_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emoji = await Emoji.find({
|
||||||
|
where: { guild_id: guild_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = await Role.find({
|
||||||
|
where: { guild_id: guild_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const stickers = await Sticker.find({
|
||||||
|
where: { guild_id: guild_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Member.addToGuild(member_id, guild_id);
|
||||||
|
res.send({ ...guild, emojis: emoji, roles: roles, stickers: stickers });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, member_id } = req.params;
|
||||||
|
const permission = await getPermission(req.user_id, guild_id);
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
if (member_id === "@me" || member_id === req.user_id) {
|
||||||
|
// TODO: unless force-joined
|
||||||
|
rights.hasThrow("SELF_LEAVE_GROUPS");
|
||||||
|
} else {
|
||||||
|
rights.hasThrow("KICK_BAN_MEMBERS");
|
||||||
|
permission.hasThrow("KICK_MEMBERS");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Member.removeFromGuild(member_id, guild_id);
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
55
src/api/routes/guilds/#guild_id/members/#member_id/nick.ts
Normal file
55
src/api/routes/guilds/#guild_id/members/#member_id/nick.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { getPermission, Member, PermissionResolvable } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "MemberNickChangeSchema",
|
||||||
|
responses: {
|
||||||
|
200: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
let permissionString: PermissionResolvable = "MANAGE_NICKNAMES";
|
||||||
|
const member_id =
|
||||||
|
req.params.member_id === "@me"
|
||||||
|
? ((permissionString = "CHANGE_NICKNAME"), req.user_id)
|
||||||
|
: req.params.member_id;
|
||||||
|
|
||||||
|
const perms = await getPermission(req.user_id, guild_id);
|
||||||
|
perms.hasThrow(permissionString);
|
||||||
|
|
||||||
|
await Member.changeNickname(member_id, guild_id, req.body.nick);
|
||||||
|
res.status(200).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Member } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_ROLES",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, role_id, member_id } = req.params;
|
||||||
|
|
||||||
|
await Member.removeRole(member_id, guild_id, role_id);
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_ROLES",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
403: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, role_id, member_id } = req.params;
|
||||||
|
|
||||||
|
await Member.addRole(member_id, guild_id, role_id);
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
73
src/api/routes/guilds/#guild_id/members/index.ts
Normal file
73
src/api/routes/guilds/#guild_id/members/index.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Member, PublicMemberProjection } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { MoreThan } from "typeorm";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// TODO: send over websocket
|
||||||
|
// TODO: check for GUILD_MEMBERS intent
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
query: {
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description:
|
||||||
|
"max number of members to return (1-1000). default 1",
|
||||||
|
},
|
||||||
|
after: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIMemberArray",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const limit = Number(req.query.limit) || 1;
|
||||||
|
if (limit > 1000 || limit < 1)
|
||||||
|
throw new HTTPError("Limit must be between 1 and 1000");
|
||||||
|
const after = `${req.query.after}`;
|
||||||
|
const query = after ? { id: MoreThan(after) } : {};
|
||||||
|
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
|
||||||
|
const members = await Member.find({
|
||||||
|
where: { guild_id, ...query },
|
||||||
|
select: PublicMemberProjection,
|
||||||
|
take: limit,
|
||||||
|
order: { id: "ASC" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(members);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
177
src/api/routes/guilds/#guild_id/messages/search.ts
Normal file
177
src/api/routes/guilds/#guild_id/messages/search.ts
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Channel, FieldErrors, Message, getPermission } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import { FindManyOptions, In, Like } from "typeorm";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildMessagesSearchResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
422: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const {
|
||||||
|
channel_id,
|
||||||
|
content,
|
||||||
|
// include_nsfw, // TODO
|
||||||
|
offset,
|
||||||
|
sort_order,
|
||||||
|
// sort_by, // TODO: Handle 'relevance'
|
||||||
|
limit,
|
||||||
|
author_id,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const parsedLimit = Number(limit) || 50;
|
||||||
|
if (parsedLimit < 1 || parsedLimit > 100)
|
||||||
|
throw new HTTPError("limit must be between 1 and 100", 422);
|
||||||
|
|
||||||
|
if (sort_order) {
|
||||||
|
if (
|
||||||
|
typeof sort_order != "string" ||
|
||||||
|
["desc", "asc"].indexOf(sort_order) == -1
|
||||||
|
)
|
||||||
|
throw FieldErrors({
|
||||||
|
sort_order: {
|
||||||
|
message: "Value must be one of ('desc', 'asc').",
|
||||||
|
code: "BASE_TYPE_CHOICES",
|
||||||
|
},
|
||||||
|
}); // todo this is wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
req.params.guild_id,
|
||||||
|
channel_id as string | undefined,
|
||||||
|
);
|
||||||
|
permissions.hasThrow("VIEW_CHANNEL");
|
||||||
|
if (!permissions.has("READ_MESSAGE_HISTORY"))
|
||||||
|
return res.json({ messages: [], total_results: 0 });
|
||||||
|
|
||||||
|
const query: FindManyOptions<Message> = {
|
||||||
|
order: {
|
||||||
|
timestamp: sort_order
|
||||||
|
? (sort_order.toUpperCase() as "ASC" | "DESC")
|
||||||
|
: "DESC",
|
||||||
|
},
|
||||||
|
take: parsedLimit || 0,
|
||||||
|
where: {
|
||||||
|
guild: {
|
||||||
|
id: req.params.guild_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
relations: [
|
||||||
|
"author",
|
||||||
|
"webhook",
|
||||||
|
"application",
|
||||||
|
"mentions",
|
||||||
|
"mention_roles",
|
||||||
|
"mention_channels",
|
||||||
|
"sticker_items",
|
||||||
|
"attachments",
|
||||||
|
],
|
||||||
|
skip: offset ? Number(offset) : 0,
|
||||||
|
};
|
||||||
|
//@ts-ignore
|
||||||
|
if (channel_id) query.where.channel = { id: channel_id };
|
||||||
|
else {
|
||||||
|
// get all channel IDs that this user can access
|
||||||
|
const channels = await Channel.find({
|
||||||
|
where: { guild_id: req.params.guild_id },
|
||||||
|
select: ["id"],
|
||||||
|
});
|
||||||
|
const ids = [];
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
const perm = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
req.params.guild_id,
|
||||||
|
channel.id,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!perm.has("VIEW_CHANNEL") ||
|
||||||
|
!perm.has("READ_MESSAGE_HISTORY")
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
ids.push(channel.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
query.where.channel = { id: In(ids) };
|
||||||
|
}
|
||||||
|
//@ts-ignore
|
||||||
|
if (author_id) query.where.author = { id: author_id };
|
||||||
|
//@ts-ignore
|
||||||
|
if (content) query.where.content = Like(`%${content}%`);
|
||||||
|
|
||||||
|
const messages: Message[] = await Message.find(query);
|
||||||
|
|
||||||
|
const messagesDto = messages.map((x) => [
|
||||||
|
{
|
||||||
|
id: x.id,
|
||||||
|
type: x.type,
|
||||||
|
content: x.content,
|
||||||
|
channel_id: x.channel_id,
|
||||||
|
author: {
|
||||||
|
id: x.author?.id,
|
||||||
|
username: x.author?.username,
|
||||||
|
avatar: x.author?.avatar,
|
||||||
|
avatar_decoration: null,
|
||||||
|
discriminator: x.author?.discriminator,
|
||||||
|
public_flags: x.author?.public_flags,
|
||||||
|
},
|
||||||
|
attachments: x.attachments,
|
||||||
|
embeds: x.embeds,
|
||||||
|
mentions: x.mentions,
|
||||||
|
mention_roles: x.mention_roles,
|
||||||
|
pinned: x.pinned,
|
||||||
|
mention_everyone: x.mention_everyone,
|
||||||
|
tts: x.tts,
|
||||||
|
timestamp: x.timestamp,
|
||||||
|
edited_timestamp: x.edited_timestamp,
|
||||||
|
flags: x.flags,
|
||||||
|
components: x.components,
|
||||||
|
poll: x.poll,
|
||||||
|
hit: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
messages: messagesDto,
|
||||||
|
total_results: messages.length,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
28
src/api/routes/guilds/#guild_id/premium.ts
Normal file
28
src/api/routes/guilds/#guild_id/premium.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/subscriptions", route({}), async (req: Request, res: Response) => {
|
||||||
|
// TODO:
|
||||||
|
res.json([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
80
src/api/routes/guilds/#guild_id/profile/index.ts
Normal file
80
src/api/routes/guilds/#guild_id/profile/index.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
emitEvent,
|
||||||
|
GuildMemberUpdateEvent,
|
||||||
|
handleFile,
|
||||||
|
Member,
|
||||||
|
MemberChangeProfileSchema,
|
||||||
|
OrmUtils,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/:member_id",
|
||||||
|
route({
|
||||||
|
requestBody: "MemberChangeProfileSchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Member",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
// const member_id =
|
||||||
|
// req.params.member_id === "@me" ? req.user_id : req.params.member_id;
|
||||||
|
const body = req.body as MemberChangeProfileSchema;
|
||||||
|
|
||||||
|
let member = await Member.findOneOrFail({
|
||||||
|
where: { id: req.user_id, guild_id },
|
||||||
|
relations: ["roles", "user"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (body.banner)
|
||||||
|
body.banner = await handleFile(
|
||||||
|
`/guilds/${guild_id}/users/${req.user_id}/avatars`,
|
||||||
|
body.banner as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
member = await OrmUtils.mergeDeep(member, body);
|
||||||
|
|
||||||
|
await member.save();
|
||||||
|
|
||||||
|
// do not use promise.all as we have to first write to db before emitting the event to catch errors
|
||||||
|
await emitEvent({
|
||||||
|
event: "GUILD_MEMBER_UPDATE",
|
||||||
|
guild_id,
|
||||||
|
data: { ...member, roles: member.roles.map((x) => x.id) },
|
||||||
|
} as GuildMemberUpdateEvent);
|
||||||
|
|
||||||
|
res.json(member);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
145
src/api/routes/guilds/#guild_id/prune.ts
Normal file
145
src/api/routes/guilds/#guild_id/prune.ts
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Guild, Member, Snowflake } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { IsNull, LessThan } from "typeorm";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
//Returns all inactive members, respecting role hierarchy
|
||||||
|
const inactiveMembers = async (
|
||||||
|
guild_id: string,
|
||||||
|
user_id: string,
|
||||||
|
days: number,
|
||||||
|
roles: string[] = [],
|
||||||
|
) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - days);
|
||||||
|
//Snowflake should have `generateFromTime` method? Or similar?
|
||||||
|
const minId = BigInt(date.valueOf() - Snowflake.EPOCH) << BigInt(22);
|
||||||
|
|
||||||
|
/**
|
||||||
|
idea: ability to customise the cutoff variable
|
||||||
|
possible candidates: public read receipt, last presence, last VC leave
|
||||||
|
**/
|
||||||
|
let members = await Member.find({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
guild_id,
|
||||||
|
last_message_id: LessThan(minId.toString()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
guild_id,
|
||||||
|
last_message_id: IsNull(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relations: ["roles"],
|
||||||
|
});
|
||||||
|
if (!members.length) return [];
|
||||||
|
|
||||||
|
//I'm sure I can do this in the above db query ( and it would probably be better to do so ), but oh well.
|
||||||
|
if (roles.length && members.length)
|
||||||
|
members = members.filter((user) =>
|
||||||
|
user.roles?.some((role) => roles.includes(role.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const me = await Member.findOneOrFail({
|
||||||
|
where: { id: user_id, guild_id },
|
||||||
|
relations: ["roles"],
|
||||||
|
});
|
||||||
|
const myHighestRole = Math.max(...(me.roles?.map((x) => x.position) || []));
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
|
||||||
|
|
||||||
|
members = members.filter(
|
||||||
|
(member) =>
|
||||||
|
member.id !== guild.owner_id && //can't kick owner
|
||||||
|
member.roles?.some(
|
||||||
|
(role) =>
|
||||||
|
role.position < myHighestRole || //roles higher than me can't be kicked
|
||||||
|
me.id === guild.owner_id, //owner can kick anyone
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return members;
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
body: "GuildPruneResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const days = parseInt(req.query.days as string);
|
||||||
|
|
||||||
|
let roles = req.query.include_roles;
|
||||||
|
if (typeof roles === "string") roles = [roles]; //express will return array otherwise
|
||||||
|
|
||||||
|
const members = await inactiveMembers(
|
||||||
|
req.params.guild_id,
|
||||||
|
req.user_id,
|
||||||
|
days,
|
||||||
|
roles as string[],
|
||||||
|
);
|
||||||
|
|
||||||
|
res.send({ pruned: members.length });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "KICK_MEMBERS",
|
||||||
|
right: "KICK_BAN_MEMBERS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildPurgeResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const days = parseInt(req.body.days);
|
||||||
|
|
||||||
|
let roles = req.query.include_roles;
|
||||||
|
if (typeof roles === "string") roles = [roles];
|
||||||
|
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const members = await inactiveMembers(
|
||||||
|
guild_id,
|
||||||
|
req.user_id,
|
||||||
|
days,
|
||||||
|
roles as string[],
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
members.map((x) => Member.removeFromGuild(x.id, guild_id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.send({ purged: members.length });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
50
src/api/routes/guilds/#guild_id/regions.ts
Normal file
50
src/api/routes/guilds/#guild_id/regions.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getIpAdress, getVoiceRegions, route } from "@valkyrie/api";
|
||||||
|
import { Guild } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIGuildVoiceRegion",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
|
||||||
|
//TODO we should use an enum for guild's features and not hardcoded strings
|
||||||
|
return res.json(
|
||||||
|
await getVoiceRegions(
|
||||||
|
getIpAdress(req),
|
||||||
|
guild.features.includes("VIP_REGIONS"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
160
src/api/routes/guilds/#guild_id/roles/#role_id/index.ts
Normal file
160
src/api/routes/guilds/#guild_id/roles/#role_id/index.ts
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
emitEvent,
|
||||||
|
GuildRoleDeleteEvent,
|
||||||
|
GuildRoleUpdateEvent,
|
||||||
|
handleFile,
|
||||||
|
Member,
|
||||||
|
Role,
|
||||||
|
RoleModifySchema,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Role",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, role_id } = req.params;
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
const role = await Role.findOneOrFail({
|
||||||
|
where: { guild_id, id: role_id },
|
||||||
|
});
|
||||||
|
return res.json(role);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_ROLES",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, role_id } = req.params;
|
||||||
|
if (role_id === guild_id)
|
||||||
|
throw new HTTPError("You can't delete the @everyone role");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
Role.delete({
|
||||||
|
id: role_id,
|
||||||
|
guild_id: guild_id,
|
||||||
|
}),
|
||||||
|
emitEvent({
|
||||||
|
event: "GUILD_ROLE_DELETE",
|
||||||
|
guild_id,
|
||||||
|
data: {
|
||||||
|
guild_id,
|
||||||
|
role_id,
|
||||||
|
},
|
||||||
|
} as GuildRoleDeleteEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: check role hierarchy
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "RoleModifySchema",
|
||||||
|
permission: "MANAGE_ROLES",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Role",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { role_id, guild_id } = req.params;
|
||||||
|
const body = req.body as RoleModifySchema;
|
||||||
|
|
||||||
|
if (body.icon && body.icon.length)
|
||||||
|
body.icon = await handleFile(
|
||||||
|
`/role-icons/${role_id}`,
|
||||||
|
body.icon as string,
|
||||||
|
);
|
||||||
|
else body.icon = undefined;
|
||||||
|
|
||||||
|
const role = await Role.findOneOrFail({
|
||||||
|
where: { id: role_id, guild: { id: guild_id } },
|
||||||
|
});
|
||||||
|
role.assign({
|
||||||
|
...body,
|
||||||
|
permissions: String(
|
||||||
|
(req.permission?.bitfield || 0n) &
|
||||||
|
BigInt(body.permissions || "0"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
role.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "GUILD_ROLE_UPDATE",
|
||||||
|
guild_id,
|
||||||
|
data: {
|
||||||
|
guild_id,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
} as GuildRoleUpdateEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json(role);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
42
src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts
Normal file
42
src/api/routes/guilds/#guild_id/roles/#role_id/member-ids.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { Member } from "@valkyrie/util";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", route({}), async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, role_id } = req.params;
|
||||||
|
|
||||||
|
// TODO: Is this route really not paginated?
|
||||||
|
const members = await Member.find({
|
||||||
|
select: ["id"],
|
||||||
|
where: {
|
||||||
|
roles: {
|
||||||
|
id: role_id,
|
||||||
|
},
|
||||||
|
guild_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(members.map((x) => x.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
62
src/api/routes/guilds/#guild_id/roles/#role_id/members.ts
Normal file
62
src/api/routes/guilds/#guild_id/roles/#role_id/members.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { DiscordApiErrors, Member, partition } from "@valkyrie/util";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({ permission: "MANAGE_ROLES" }),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// Payload is JSON containing a list of member_ids, the new list of members to have the role
|
||||||
|
const { guild_id, role_id } = req.params;
|
||||||
|
const { member_ids } = req.body;
|
||||||
|
|
||||||
|
// don't mess with @everyone
|
||||||
|
if (role_id == guild_id) throw DiscordApiErrors.INVALID_ROLE;
|
||||||
|
|
||||||
|
const members = await Member.find({
|
||||||
|
where: { guild_id },
|
||||||
|
relations: ["roles"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [add, remove] = partition(
|
||||||
|
members,
|
||||||
|
(member) =>
|
||||||
|
member_ids.includes(member.id) &&
|
||||||
|
!member.roles.map((role) => role.id).includes(role_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO (erkin): have a bulk add/remove function that adds the roles in a single txn
|
||||||
|
await Promise.all([
|
||||||
|
...add.map((member) =>
|
||||||
|
Member.addRole(member.id, guild_id, role_id),
|
||||||
|
),
|
||||||
|
...remove.map((member) =>
|
||||||
|
Member.removeRole(member.id, guild_id, role_id),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
166
src/api/routes/guilds/#guild_id/roles/index.ts
Normal file
166
src/api/routes/guilds/#guild_id/roles/index.ts
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
DiscordApiErrors,
|
||||||
|
emitEvent,
|
||||||
|
GuildRoleCreateEvent,
|
||||||
|
GuildRoleUpdateEvent,
|
||||||
|
Member,
|
||||||
|
Role,
|
||||||
|
RoleModifySchema,
|
||||||
|
RolePositionUpdateSchema,
|
||||||
|
Snowflake,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { Not } from "typeorm";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get("/", route({}), async (req: Request, res: Response) => {
|
||||||
|
const guild_id = req.params.guild_id;
|
||||||
|
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
|
||||||
|
const roles = await Role.find({ where: { guild_id: guild_id } });
|
||||||
|
|
||||||
|
return res.json(roles);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "RoleModifySchema",
|
||||||
|
permission: "MANAGE_ROLES",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Role",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const guild_id = req.params.guild_id;
|
||||||
|
const body = req.body as RoleModifySchema;
|
||||||
|
|
||||||
|
const role_count = await Role.count({ where: { guild_id } });
|
||||||
|
const { maxRoles } = Config.get().limits.guild;
|
||||||
|
|
||||||
|
if (role_count > maxRoles)
|
||||||
|
throw DiscordApiErrors.MAXIMUM_ROLES.withParams(maxRoles);
|
||||||
|
|
||||||
|
const role = Role.create({
|
||||||
|
// values before ...body are default and can be overriden
|
||||||
|
position: 1,
|
||||||
|
hoist: false,
|
||||||
|
color: 0,
|
||||||
|
mentionable: false,
|
||||||
|
...body,
|
||||||
|
guild_id: guild_id,
|
||||||
|
managed: false,
|
||||||
|
permissions: String(
|
||||||
|
(req.permission?.bitfield || 0n) &
|
||||||
|
BigInt(body.permissions || "0"),
|
||||||
|
),
|
||||||
|
tags: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
unicode_emoji: undefined,
|
||||||
|
id: Snowflake.generate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
role.save(),
|
||||||
|
// Move all existing roles up one position, to accommodate the new role
|
||||||
|
Role.createQueryBuilder("roles")
|
||||||
|
.where({
|
||||||
|
guild: { id: guild_id },
|
||||||
|
name: Not("@everyone"),
|
||||||
|
id: Not(role.id),
|
||||||
|
})
|
||||||
|
.update({ position: () => "position + 1" })
|
||||||
|
.execute(),
|
||||||
|
emitEvent({
|
||||||
|
event: "GUILD_ROLE_CREATE",
|
||||||
|
guild_id,
|
||||||
|
data: {
|
||||||
|
guild_id,
|
||||||
|
role: role,
|
||||||
|
},
|
||||||
|
} as GuildRoleCreateEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json(role);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "RolePositionUpdateSchema",
|
||||||
|
permission: "MANAGE_ROLES",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIRoleArray",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const body = req.body as RolePositionUpdateSchema;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
body.map(async (x) =>
|
||||||
|
Role.update({ guild_id, id: x.id }, { position: x.position }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const roles = await Role.find({
|
||||||
|
where: body.map((x) => ({ id: x.id, guild_id })),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
roles.map((x) =>
|
||||||
|
emitEvent({
|
||||||
|
event: "GUILD_ROLE_UPDATE",
|
||||||
|
guild_id,
|
||||||
|
data: {
|
||||||
|
guild_id,
|
||||||
|
role: x,
|
||||||
|
},
|
||||||
|
} as GuildRoleUpdateEvent),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(roles);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
39
src/api/routes/guilds/#guild_id/roles/member-counts.ts
Normal file
39
src/api/routes/guilds/#guild_id/roles/member-counts.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { Role, Member } from "@valkyrie/util";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {} from "typeorm";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get("/", route({}), async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
|
||||||
|
const role_ids = await Role.find({ where: { guild_id }, select: ["id"] });
|
||||||
|
const counts: { [id: string]: number } = {};
|
||||||
|
for (const { id } of role_ids) {
|
||||||
|
counts[id] = await Member.count({ where: { roles: { id }, guild_id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(counts);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
213
src/api/routes/guilds/#guild_id/stickers.ts
Normal file
213
src/api/routes/guilds/#guild_id/stickers.ts
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
GuildStickersUpdateEvent,
|
||||||
|
Member,
|
||||||
|
ModifyGuildStickerSchema,
|
||||||
|
Snowflake,
|
||||||
|
Sticker,
|
||||||
|
StickerFormatType,
|
||||||
|
StickerType,
|
||||||
|
emitEvent,
|
||||||
|
uploadFile,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import multer from "multer";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIStickerArray",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
|
||||||
|
res.json(await Sticker.find({ where: { guild_id } }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const bodyParser = multer({
|
||||||
|
limits: {
|
||||||
|
fileSize: 1024 * 1024 * 100,
|
||||||
|
fields: 10,
|
||||||
|
files: 1,
|
||||||
|
},
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
}).single("file");
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
bodyParser,
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_EMOJIS_AND_STICKERS",
|
||||||
|
requestBody: "ModifyGuildStickerSchema",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Sticker",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
if (!req.file) throw new HTTPError("missing file");
|
||||||
|
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const body = req.body as ModifyGuildStickerSchema;
|
||||||
|
const id = Snowflake.generate();
|
||||||
|
|
||||||
|
const [sticker] = await Promise.all([
|
||||||
|
Sticker.create({
|
||||||
|
...body,
|
||||||
|
guild_id,
|
||||||
|
id,
|
||||||
|
type: StickerType.GUILD,
|
||||||
|
format_type: getStickerFormat(req.file.mimetype),
|
||||||
|
available: true,
|
||||||
|
}).save(),
|
||||||
|
uploadFile(`/stickers/${id}`, req.file),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sendStickerUpdateEvent(guild_id);
|
||||||
|
|
||||||
|
res.json(sticker);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function getStickerFormat(mime_type: string) {
|
||||||
|
switch (mime_type) {
|
||||||
|
case "image/apng":
|
||||||
|
return StickerFormatType.APNG;
|
||||||
|
case "application/json":
|
||||||
|
return StickerFormatType.LOTTIE;
|
||||||
|
case "image/png":
|
||||||
|
return StickerFormatType.PNG;
|
||||||
|
case "image/gif":
|
||||||
|
return StickerFormatType.GIF;
|
||||||
|
default:
|
||||||
|
throw new HTTPError(
|
||||||
|
"invalid sticker format: must be png, apng or lottie",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:sticker_id",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Sticker",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, sticker_id } = req.params;
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
await Sticker.findOneOrFail({
|
||||||
|
where: { guild_id, id: sticker_id },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/:sticker_id",
|
||||||
|
route({
|
||||||
|
requestBody: "ModifyGuildStickerSchema",
|
||||||
|
permission: "MANAGE_EMOJIS_AND_STICKERS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Sticker",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, sticker_id } = req.params;
|
||||||
|
const body = req.body as ModifyGuildStickerSchema;
|
||||||
|
|
||||||
|
const sticker = await Sticker.create({
|
||||||
|
...body,
|
||||||
|
guild_id,
|
||||||
|
id: sticker_id,
|
||||||
|
}).save();
|
||||||
|
await sendStickerUpdateEvent(guild_id);
|
||||||
|
|
||||||
|
return res.json(sticker);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function sendStickerUpdateEvent(guild_id: string) {
|
||||||
|
return emitEvent({
|
||||||
|
event: "GUILD_STICKERS_UPDATE",
|
||||||
|
guild_id: guild_id,
|
||||||
|
data: {
|
||||||
|
guild_id: guild_id,
|
||||||
|
stickers: await Sticker.find({ where: { guild_id: guild_id } }),
|
||||||
|
},
|
||||||
|
} as GuildStickersUpdateEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:sticker_id",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_EMOJIS_AND_STICKERS",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id, sticker_id } = req.params;
|
||||||
|
|
||||||
|
await Sticker.delete({ guild_id, id: sticker_id });
|
||||||
|
await sendStickerUpdateEvent(guild_id);
|
||||||
|
|
||||||
|
return res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
179
src/api/routes/guilds/#guild_id/templates.ts
Normal file
179
src/api/routes/guilds/#guild_id/templates.ts
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateCode, route } from "@valkyrie/api";
|
||||||
|
import { Guild, Template } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
const TemplateGuildProjection: (keyof Guild)[] = [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"region",
|
||||||
|
"verification_level",
|
||||||
|
"default_message_notifications",
|
||||||
|
"explicit_content_filter",
|
||||||
|
"preferred_locale",
|
||||||
|
"afk_timeout",
|
||||||
|
"roles",
|
||||||
|
// "channels",
|
||||||
|
"afk_channel_id",
|
||||||
|
"system_channel_id",
|
||||||
|
"system_channel_flags",
|
||||||
|
"icon",
|
||||||
|
];
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APITemplateArray",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const templates = await Template.find({
|
||||||
|
where: { source_guild_id: guild_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(templates);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "TemplateCreateSchema",
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Template",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { id: guild_id },
|
||||||
|
select: TemplateGuildProjection,
|
||||||
|
});
|
||||||
|
const exists = await Template.findOne({
|
||||||
|
where: { id: guild_id },
|
||||||
|
});
|
||||||
|
if (exists) throw new HTTPError("Template already exists", 400);
|
||||||
|
|
||||||
|
const template = await Template.create({
|
||||||
|
...req.body,
|
||||||
|
code: generateCode(),
|
||||||
|
creator_id: req.user_id,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
source_guild_id: guild_id,
|
||||||
|
serialized_source_guild: guild,
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
res.json(template);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:code",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
200: { body: "Template" },
|
||||||
|
403: { body: "APIErrorResponse" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { code, guild_id } = req.params;
|
||||||
|
|
||||||
|
const template = await Template.delete({
|
||||||
|
code,
|
||||||
|
source_guild_id: guild_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(template);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
"/:code",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
200: { body: "Template" },
|
||||||
|
403: { body: "APIErrorResponse" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { code, guild_id } = req.params;
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { id: guild_id },
|
||||||
|
select: TemplateGuildProjection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = await Template.create({
|
||||||
|
code,
|
||||||
|
serialized_source_guild: guild,
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
res.json(template);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/:code",
|
||||||
|
route({
|
||||||
|
requestBody: "TemplateModifySchema",
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
200: { body: "Template" },
|
||||||
|
403: { body: "APIErrorResponse" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { code, guild_id } = req.params;
|
||||||
|
const { name, description } = req.body;
|
||||||
|
|
||||||
|
const template = await Template.create({
|
||||||
|
code,
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
source_guild_id: guild_id,
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
res.json(template);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
131
src/api/routes/guilds/#guild_id/vanity-url.ts
Normal file
131
src/api/routes/guilds/#guild_id/vanity-url.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
ChannelType,
|
||||||
|
Guild,
|
||||||
|
Invite,
|
||||||
|
VanityUrlSchema,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const InviteRegex = /\W/g;
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildVanityUrlResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
|
||||||
|
|
||||||
|
if (!guild.features.includes("ALIASABLE_NAMES")) {
|
||||||
|
const invite = await Invite.findOne({
|
||||||
|
where: { guild_id: guild_id, vanity_url: true },
|
||||||
|
});
|
||||||
|
if (!invite) return res.json({ code: null });
|
||||||
|
|
||||||
|
return res.json({ code: invite.code, uses: invite.uses });
|
||||||
|
} else {
|
||||||
|
const invite = await Invite.find({
|
||||||
|
where: { guild_id: guild_id, vanity_url: true },
|
||||||
|
});
|
||||||
|
if (!invite || invite.length == 0) return res.json({ code: null });
|
||||||
|
|
||||||
|
return res.json(
|
||||||
|
invite.map((x) => ({ code: x.code, uses: x.uses })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "VanityUrlSchema",
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildVanityUrlCreateResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const body = req.body as VanityUrlSchema;
|
||||||
|
const code = body.code?.replace(InviteRegex, "");
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
|
||||||
|
if (!guild.features.includes("VANITY_URL"))
|
||||||
|
throw new HTTPError("Your guild doesn't support vanity urls");
|
||||||
|
|
||||||
|
if (!code || code.length === 0)
|
||||||
|
throw new HTTPError("Code cannot be null or empty");
|
||||||
|
|
||||||
|
const invite = await Invite.findOne({ where: { code } });
|
||||||
|
if (invite) throw new HTTPError("Invite already exists");
|
||||||
|
|
||||||
|
const { id } = await Channel.findOneOrFail({
|
||||||
|
where: { guild_id, type: ChannelType.GUILD_TEXT },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!guild.features.includes("ALIASABLE_NAMES")) {
|
||||||
|
await Invite.delete({ guild_id, vanity_url: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Invite.create({
|
||||||
|
vanity_url: true,
|
||||||
|
code,
|
||||||
|
temporary: false,
|
||||||
|
uses: 0,
|
||||||
|
max_uses: 0,
|
||||||
|
max_age: 0,
|
||||||
|
created_at: new Date(),
|
||||||
|
guild_id: guild_id,
|
||||||
|
channel_id: id,
|
||||||
|
flags: 0,
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
return res.json({ code });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
104
src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts
Normal file
104
src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
ChannelType,
|
||||||
|
DiscordApiErrors,
|
||||||
|
emitEvent,
|
||||||
|
getPermission,
|
||||||
|
VoiceState,
|
||||||
|
VoiceStateUpdateEvent,
|
||||||
|
VoiceStateUpdateSchema,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
//TODO need more testing when community guild and voice stage channel are working
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "VoiceStateUpdateSchema",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as VoiceStateUpdateSchema;
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const user_id =
|
||||||
|
req.params.user_id === "@me" ? req.user_id : req.params.user_id;
|
||||||
|
|
||||||
|
const perms = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
guild_id,
|
||||||
|
body.channel_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
From https://discord.com/developers/docs/resources/guild#modify-current-user-voice-state
|
||||||
|
You must have the MUTE_MEMBERS permission to unsuppress others. You can always suppress yourself.
|
||||||
|
You must have the REQUEST_TO_SPEAK permission to request to speak. You can always clear your own request to speak.
|
||||||
|
*/
|
||||||
|
if (body.suppress && user_id !== req.user_id) {
|
||||||
|
perms.hasThrow("MUTE_MEMBERS");
|
||||||
|
}
|
||||||
|
if (!body.suppress) body.request_to_speak_timestamp = new Date();
|
||||||
|
if (body.request_to_speak_timestamp) perms.hasThrow("REQUEST_TO_SPEAK");
|
||||||
|
|
||||||
|
const voice_state = await VoiceState.findOne({
|
||||||
|
where: {
|
||||||
|
guild_id,
|
||||||
|
channel_id: body.channel_id,
|
||||||
|
user_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!voice_state) throw DiscordApiErrors.UNKNOWN_VOICE_STATE;
|
||||||
|
|
||||||
|
voice_state.assign(body);
|
||||||
|
const channel = await Channel.findOneOrFail({
|
||||||
|
where: { guild_id, id: body.channel_id },
|
||||||
|
});
|
||||||
|
if (channel.type !== ChannelType.GUILD_STAGE_VOICE) {
|
||||||
|
throw DiscordApiErrors.CANNOT_EXECUTE_ON_THIS_CHANNEL_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
voice_state.save(),
|
||||||
|
emitEvent({
|
||||||
|
event: "VOICE_STATE_UPDATE",
|
||||||
|
data: voice_state,
|
||||||
|
guild_id,
|
||||||
|
} as VoiceStateUpdateEvent),
|
||||||
|
]);
|
||||||
|
return res.sendStatus(204);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
66
src/api/routes/guilds/#guild_id/webhooks.ts
Normal file
66
src/api/routes/guilds/#guild_id/webhooks.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Config, Webhook } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
description:
|
||||||
|
"Returns a list of guild webhook objects. Requires the MANAGE_WEBHOOKS permission.",
|
||||||
|
permission: "MANAGE_WEBHOOKS",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "APIWebhookArray",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
const webhooks = await Webhook.find({
|
||||||
|
where: { guild_id },
|
||||||
|
relations: [
|
||||||
|
"user",
|
||||||
|
"channel",
|
||||||
|
"source_channel",
|
||||||
|
"guild",
|
||||||
|
"source_guild",
|
||||||
|
"application",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const instanceUrl =
|
||||||
|
Config.get().api.endpointPublic || "http://localhost:3001";
|
||||||
|
return res.json(
|
||||||
|
webhooks.map((webhook) => ({
|
||||||
|
...webhook,
|
||||||
|
url:
|
||||||
|
instanceUrl +
|
||||||
|
"/webhooks/" +
|
||||||
|
webhook.id +
|
||||||
|
"/" +
|
||||||
|
webhook.token,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
98
src/api/routes/guilds/#guild_id/welcome-screen.ts
Normal file
98
src/api/routes/guilds/#guild_id/welcome-screen.ts
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
Guild,
|
||||||
|
GuildUpdateWelcomeScreenSchema,
|
||||||
|
Member,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildWelcomeScreen",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const guild_id = req.params.guild_id;
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
|
||||||
|
await Member.IsInGuildOrFail(req.user_id, guild_id);
|
||||||
|
|
||||||
|
res.json(guild.welcome_screen);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "GuildUpdateWelcomeScreenSchema",
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
204: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const guild_id = req.params.guild_id;
|
||||||
|
const body = req.body as GuildUpdateWelcomeScreenSchema;
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
|
||||||
|
|
||||||
|
if (body.enabled != undefined)
|
||||||
|
guild.welcome_screen.enabled = body.enabled;
|
||||||
|
|
||||||
|
if (body.description != undefined)
|
||||||
|
guild.welcome_screen.description = body.description;
|
||||||
|
|
||||||
|
if (body.welcome_channels != undefined) {
|
||||||
|
// Ensure channels exist within the guild
|
||||||
|
await Promise.all(
|
||||||
|
body.welcome_channels?.map(({ channel_id }) =>
|
||||||
|
Channel.findOneOrFail({
|
||||||
|
where: { id: channel_id, guild_id },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
) || [],
|
||||||
|
);
|
||||||
|
guild.welcome_screen.welcome_channels = body.welcome_channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guild.save();
|
||||||
|
|
||||||
|
res.status(200).json(guild.welcome_screen);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
132
src/api/routes/guilds/#guild_id/widget.json.ts
Normal file
132
src/api/routes/guilds/#guild_id/widget.json.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { random, route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Channel,
|
||||||
|
DiscordApiErrors,
|
||||||
|
Guild,
|
||||||
|
Invite,
|
||||||
|
Member,
|
||||||
|
Permissions,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
// Undocumented API notes:
|
||||||
|
// An invite is created for the widget_channel_id on request (only if an existing one created by the widget doesn't already exist)
|
||||||
|
// This invite created doesn't include an inviter object like user created ones and has a default expiry of 24 hours
|
||||||
|
// Missing user object information is intentional (https://github.com/discord/discord-api-docs/issues/1287)
|
||||||
|
// channels returns voice channel objects where @everyone has the CONNECT permission
|
||||||
|
// members (max 100 returned) is a sample of all members, and bots par invisible status, there exists some alphabetical distribution pattern between the members returned
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/guild#get-guild-widget
|
||||||
|
// TODO: Cache the response for a guild for 5 minutes regardless of response
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildWidgetJsonResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { id: guild_id },
|
||||||
|
select: {
|
||||||
|
channel_ordering: true,
|
||||||
|
widget_channel_id: true,
|
||||||
|
widget_enabled: true,
|
||||||
|
presence_count: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!guild.widget_enabled) throw DiscordApiErrors.EMBED_DISABLED;
|
||||||
|
|
||||||
|
// Fetch existing widget invite for widget channel
|
||||||
|
let invite = await Invite.findOne({
|
||||||
|
where: { channel_id: guild.widget_channel_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (guild.widget_channel_id && !invite) {
|
||||||
|
// Create invite for channel if none exists
|
||||||
|
// TODO: Refactor invite create code to a shared function
|
||||||
|
const max_age = 86400; // 24 hours
|
||||||
|
const expires_at = new Date(max_age * 1000 + Date.now());
|
||||||
|
|
||||||
|
invite = await Invite.create({
|
||||||
|
code: random(),
|
||||||
|
temporary: false,
|
||||||
|
uses: 0,
|
||||||
|
max_uses: 0,
|
||||||
|
max_age: max_age,
|
||||||
|
expires_at,
|
||||||
|
created_at: new Date(),
|
||||||
|
guild_id,
|
||||||
|
channel_id: guild.widget_channel_id,
|
||||||
|
flags: 0,
|
||||||
|
}).save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch voice channels, and the @everyone permissions object
|
||||||
|
const channels: { id: string; name: string; position: number }[] = [];
|
||||||
|
|
||||||
|
(await Channel.getOrderedChannels(guild.id, guild)).filter((doc) => {
|
||||||
|
// Only return channels where @everyone has the CONNECT permission
|
||||||
|
if (
|
||||||
|
doc.permission_overwrites === undefined ||
|
||||||
|
Permissions.channelPermission(
|
||||||
|
doc.permission_overwrites,
|
||||||
|
Permissions.FLAGS.CONNECT,
|
||||||
|
) === Permissions.FLAGS.CONNECT
|
||||||
|
) {
|
||||||
|
channels.push({
|
||||||
|
id: doc.id,
|
||||||
|
name: doc.name ?? "Unknown channel",
|
||||||
|
position: doc.position ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch members
|
||||||
|
// TODO: Understand how Discord's max 100 random member sample works, and apply to here (see top of this file)
|
||||||
|
const members = await Member.find({ where: { guild_id: guild_id } });
|
||||||
|
|
||||||
|
// Construct object to respond with
|
||||||
|
const data = {
|
||||||
|
id: guild_id,
|
||||||
|
name: guild.name,
|
||||||
|
instant_invite: invite?.code,
|
||||||
|
channels: channels,
|
||||||
|
members: members,
|
||||||
|
presence_count: guild.presence_count,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.set("Cache-Control", "public, max-age=300");
|
||||||
|
return res.json(data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
246
src/api/routes/guilds/#guild_id/widget.png.ts
Normal file
246
src/api/routes/guilds/#guild_id/widget.png.ts
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { DiscordApiErrors, Guild } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import fs from "fs";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
import path from "path";
|
||||||
|
import { storage } from "../../../../cdn/util/Storage";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
// TODO: use svg templates instead of node-canvas for improved performance and to change it easily
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/guild#get-guild-widget-image
|
||||||
|
// TODO: Cache the response
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
|
||||||
|
if (!guild.widget_enabled) throw DiscordApiErrors.EMBED_DISABLED;
|
||||||
|
|
||||||
|
// Fetch guild information
|
||||||
|
const icon = "avatars/" + guild_id + "/" + guild.icon;
|
||||||
|
const name = guild.name;
|
||||||
|
const presence = guild.presence_count + " ONLINE";
|
||||||
|
|
||||||
|
// Fetch parameter
|
||||||
|
const style = req.query.style?.toString() || "shield";
|
||||||
|
if (
|
||||||
|
!["shield", "banner1", "banner2", "banner3", "banner4"].includes(
|
||||||
|
style,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HTTPError(
|
||||||
|
"Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup canvas
|
||||||
|
const { createCanvas, loadImage } = require("canvas");
|
||||||
|
const sizeOf = require("image-size");
|
||||||
|
|
||||||
|
// TODO: Widget style templates need Spacebar branding
|
||||||
|
const source = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"assets",
|
||||||
|
"widget",
|
||||||
|
`${style}.png`,
|
||||||
|
);
|
||||||
|
if (!fs.existsSync(source)) {
|
||||||
|
throw new HTTPError("Widget template does not exist.", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create base template image for parameter
|
||||||
|
const { width, height } = await sizeOf(source);
|
||||||
|
const canvas = createCanvas(width, height);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const template = await loadImage(source);
|
||||||
|
ctx.drawImage(template, 0, 0);
|
||||||
|
|
||||||
|
// Add the guild specific information to the template asset image
|
||||||
|
switch (style) {
|
||||||
|
case "shield":
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
await drawText(
|
||||||
|
ctx,
|
||||||
|
73,
|
||||||
|
13,
|
||||||
|
"#FFFFFF",
|
||||||
|
"thin 10px Verdana",
|
||||||
|
presence,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "banner1":
|
||||||
|
if (icon) await drawIcon(ctx, 20, 27, 50, icon);
|
||||||
|
await drawText(
|
||||||
|
ctx,
|
||||||
|
83,
|
||||||
|
51,
|
||||||
|
"#FFFFFF",
|
||||||
|
"12px Verdana",
|
||||||
|
name,
|
||||||
|
22,
|
||||||
|
);
|
||||||
|
await drawText(
|
||||||
|
ctx,
|
||||||
|
83,
|
||||||
|
66,
|
||||||
|
"#C9D2F0FF",
|
||||||
|
"thin 11px Verdana",
|
||||||
|
presence,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "banner2":
|
||||||
|
if (icon) await drawIcon(ctx, 13, 19, 36, icon);
|
||||||
|
await drawText(
|
||||||
|
ctx,
|
||||||
|
62,
|
||||||
|
34,
|
||||||
|
"#FFFFFF",
|
||||||
|
"12px Verdana",
|
||||||
|
name,
|
||||||
|
15,
|
||||||
|
);
|
||||||
|
await drawText(
|
||||||
|
ctx,
|
||||||
|
62,
|
||||||
|
49,
|
||||||
|
"#C9D2F0FF",
|
||||||
|
"thin 11px Verdana",
|
||||||
|
presence,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "banner3":
|
||||||
|
if (icon) await drawIcon(ctx, 20, 20, 50, icon);
|
||||||
|
await drawText(
|
||||||
|
ctx,
|
||||||
|
83,
|
||||||
|
44,
|
||||||
|
"#FFFFFF",
|
||||||
|
"12px Verdana",
|
||||||
|
name,
|
||||||
|
27,
|
||||||
|
);
|
||||||
|
await drawText(
|
||||||
|
ctx,
|
||||||
|
83,
|
||||||
|
58,
|
||||||
|
"#C9D2F0FF",
|
||||||
|
"thin 11px Verdana",
|
||||||
|
presence,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "banner4":
|
||||||
|
if (icon) await drawIcon(ctx, 21, 136, 50, icon);
|
||||||
|
await drawText(
|
||||||
|
ctx,
|
||||||
|
84,
|
||||||
|
156,
|
||||||
|
"#FFFFFF",
|
||||||
|
"13px Verdana",
|
||||||
|
name,
|
||||||
|
27,
|
||||||
|
);
|
||||||
|
await drawText(
|
||||||
|
ctx,
|
||||||
|
84,
|
||||||
|
171,
|
||||||
|
"#C9D2F0FF",
|
||||||
|
"thin 12px Verdana",
|
||||||
|
presence,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new HTTPError(
|
||||||
|
"Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return final image
|
||||||
|
const buffer = canvas.toBuffer("image/png");
|
||||||
|
res.set("Content-Type", "image/png");
|
||||||
|
res.set("Cache-Control", "public, max-age=3600");
|
||||||
|
return res.send(buffer);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function drawIcon(
|
||||||
|
canvas: any,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
scale: number,
|
||||||
|
icon: string,
|
||||||
|
) {
|
||||||
|
const { loadImage } = require("canvas");
|
||||||
|
const img = await loadImage(await storage.get(icon));
|
||||||
|
|
||||||
|
// Do some canvas clipping magic!
|
||||||
|
canvas.save();
|
||||||
|
canvas.beginPath();
|
||||||
|
|
||||||
|
const r = scale / 2; // use scale to determine radius
|
||||||
|
canvas.arc(x + r, y + r, r, 0, 2 * Math.PI, false); // start circle at x, and y coords + radius to find center
|
||||||
|
|
||||||
|
canvas.clip();
|
||||||
|
canvas.drawImage(img, x, y, scale, scale);
|
||||||
|
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawText(
|
||||||
|
canvas: any,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
color: string,
|
||||||
|
font: string,
|
||||||
|
text: string,
|
||||||
|
maxcharacters?: number,
|
||||||
|
) {
|
||||||
|
canvas.fillStyle = color;
|
||||||
|
canvas.font = font;
|
||||||
|
if (text.length > (maxcharacters || 0) && maxcharacters)
|
||||||
|
text = text.slice(0, maxcharacters) + "...";
|
||||||
|
canvas.fillText(text, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
85
src/api/routes/guilds/#guild_id/widget.ts
Normal file
85
src/api/routes/guilds/#guild_id/widget.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import { Guild, WidgetModifySchema } from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/guild#get-guild-widget-settings
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "GuildWidgetSettingsResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
enabled: guild.widget_enabled || false,
|
||||||
|
channel_id: guild.widget_channel_id || null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/guild#modify-guild-widget
|
||||||
|
router.patch(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "WidgetModifySchema",
|
||||||
|
permission: "MANAGE_GUILD",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "WidgetModifySchema",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as WidgetModifySchema;
|
||||||
|
const { guild_id } = req.params;
|
||||||
|
|
||||||
|
await Guild.update(
|
||||||
|
{ id: guild_id },
|
||||||
|
{
|
||||||
|
widget_enabled: body.enabled,
|
||||||
|
widget_channel_id: body.channel_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Widget invite for the widget_channel_id gets created as part of the /guilds/{guild.id}/widget.json request
|
||||||
|
|
||||||
|
return res.json(body);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
79
src/api/routes/guilds/index.ts
Normal file
79
src/api/routes/guilds/index.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
DiscordApiErrors,
|
||||||
|
Guild,
|
||||||
|
GuildCreateSchema,
|
||||||
|
Member,
|
||||||
|
getRights,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
//TODO: create default channel
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "GuildCreateSchema",
|
||||||
|
right: "CREATE_GUILDS",
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
body: "GuildCreateResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as GuildCreateSchema;
|
||||||
|
|
||||||
|
const { maxGuilds } = Config.get().limits.user;
|
||||||
|
const guild_count = await Member.count({ where: { id: req.user_id } });
|
||||||
|
const rights = await getRights(req.user_id);
|
||||||
|
if (guild_count >= maxGuilds && !rights.has("MANAGE_GUILDS")) {
|
||||||
|
throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await Guild.createGuild({
|
||||||
|
...body,
|
||||||
|
owner_id: req.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autoJoin } = Config.get().guild;
|
||||||
|
if (autoJoin.enabled && !autoJoin.guilds?.length) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
await Config.set({ guild: { autoJoin: { guilds: [guild.id] } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Member.addToGuild(req.user_id, guild.id);
|
||||||
|
|
||||||
|
res.status(201).json(guild);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
169
src/api/routes/guilds/templates/index.ts
Normal file
169
src/api/routes/guilds/templates/index.ts
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
DiscordApiErrors,
|
||||||
|
Guild,
|
||||||
|
GuildTemplateCreateSchema,
|
||||||
|
Member,
|
||||||
|
Role,
|
||||||
|
Snowflake,
|
||||||
|
Template,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:code",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Template",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { allowDiscordTemplates, allowRaws, enabled } =
|
||||||
|
Config.get().templates;
|
||||||
|
if (!enabled)
|
||||||
|
res.json({
|
||||||
|
code: 403,
|
||||||
|
message:
|
||||||
|
"Template creation & usage is disabled on this instance.",
|
||||||
|
}).sendStatus(403);
|
||||||
|
|
||||||
|
const { code } = req.params;
|
||||||
|
|
||||||
|
if (code.startsWith("discord:")) {
|
||||||
|
if (!allowDiscordTemplates)
|
||||||
|
return res
|
||||||
|
.json({
|
||||||
|
code: 403,
|
||||||
|
message:
|
||||||
|
"Discord templates cannot be used on this instance.",
|
||||||
|
})
|
||||||
|
.sendStatus(403);
|
||||||
|
const discordTemplateID = code.split("discord:", 2)[1];
|
||||||
|
|
||||||
|
const discordTemplateData = await fetch(
|
||||||
|
`https://discord.com/api/v9/guilds/templates/${discordTemplateID}`,
|
||||||
|
{
|
||||||
|
method: "get",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.json(await discordTemplateData.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.startsWith("external:")) {
|
||||||
|
if (!allowRaws)
|
||||||
|
return res
|
||||||
|
.json({
|
||||||
|
code: 403,
|
||||||
|
message: "Importing raws is disabled on this instance.",
|
||||||
|
})
|
||||||
|
.sendStatus(403);
|
||||||
|
|
||||||
|
return res.json(code.split("external:", 2)[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await Template.findOneOrFail({
|
||||||
|
where: { code: code },
|
||||||
|
});
|
||||||
|
res.json(template);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/:code",
|
||||||
|
route({ requestBody: "GuildTemplateCreateSchema" }),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
allowTemplateCreation,
|
||||||
|
// allowDiscordTemplates,
|
||||||
|
// allowRaws,
|
||||||
|
} = Config.get().templates;
|
||||||
|
if (!enabled)
|
||||||
|
return res
|
||||||
|
.json({
|
||||||
|
code: 403,
|
||||||
|
message:
|
||||||
|
"Template creation & usage is disabled on this instance.",
|
||||||
|
})
|
||||||
|
.sendStatus(403);
|
||||||
|
if (!allowTemplateCreation)
|
||||||
|
return res
|
||||||
|
.json({
|
||||||
|
code: 403,
|
||||||
|
message: "Template creation is disabled on this instance.",
|
||||||
|
})
|
||||||
|
.sendStatus(403);
|
||||||
|
|
||||||
|
const { code } = req.params;
|
||||||
|
const body = req.body as GuildTemplateCreateSchema;
|
||||||
|
|
||||||
|
const { maxGuilds } = Config.get().limits.user;
|
||||||
|
|
||||||
|
const guild_count = await Member.count({ where: { id: req.user_id } });
|
||||||
|
if (guild_count >= maxGuilds) {
|
||||||
|
throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await Template.findOneOrFail({
|
||||||
|
where: { code: code },
|
||||||
|
});
|
||||||
|
|
||||||
|
const guild_id = Snowflake.generate();
|
||||||
|
|
||||||
|
const [guild] = await Promise.all([
|
||||||
|
Guild.create({
|
||||||
|
...body,
|
||||||
|
...template.serialized_source_guild,
|
||||||
|
id: guild_id,
|
||||||
|
owner_id: req.user_id,
|
||||||
|
}).save(),
|
||||||
|
Role.create({
|
||||||
|
id: guild_id,
|
||||||
|
guild_id: guild_id,
|
||||||
|
color: 0,
|
||||||
|
hoist: false,
|
||||||
|
managed: true,
|
||||||
|
mentionable: true,
|
||||||
|
name: "@everyone",
|
||||||
|
permissions: BigInt("2251804225").toString(), // TODO: where did this come from?
|
||||||
|
position: 0,
|
||||||
|
}).save(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Member.addToGuild(req.user_id, guild_id);
|
||||||
|
|
||||||
|
res.status(201).json({ id: guild.id });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
162
src/api/routes/invites/index.ts
Normal file
162
src/api/routes/invites/index.ts
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
DiscordApiErrors,
|
||||||
|
emitEvent,
|
||||||
|
getPermission,
|
||||||
|
Guild,
|
||||||
|
Invite,
|
||||||
|
InviteDeleteEvent,
|
||||||
|
PublicInviteRelation,
|
||||||
|
User,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
import { HTTPError } from "lambert-server";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:code",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
body: "Invite",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { code } = req.params;
|
||||||
|
|
||||||
|
const invite = await Invite.findOneOrFail({
|
||||||
|
where: { code },
|
||||||
|
relations: PublicInviteRelation,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).send(invite);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/:code",
|
||||||
|
route({
|
||||||
|
right: "USE_MASS_INVITES",
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
body: "Invite",
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
if (req.user_bot) throw DiscordApiErrors.BOT_PROHIBITED_ENDPOINT;
|
||||||
|
|
||||||
|
const { code } = req.params;
|
||||||
|
const { guild_id } = await Invite.findOneOrFail({
|
||||||
|
where: { code: code },
|
||||||
|
});
|
||||||
|
const { features } = await Guild.findOneOrFail({
|
||||||
|
where: { id: guild_id },
|
||||||
|
});
|
||||||
|
const { public_flags } = await User.findOneOrFail({
|
||||||
|
where: { id: req.user_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
features.includes("INTERNAL_EMPLOYEE_ONLY") &&
|
||||||
|
(public_flags & 1) !== 1
|
||||||
|
)
|
||||||
|
throw new HTTPError(
|
||||||
|
"Only intended for the staff of this server.",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
if (features.includes("INVITES_DISABLED"))
|
||||||
|
throw new HTTPError("Sorry, this guild has joins closed.", 403);
|
||||||
|
|
||||||
|
const invite = await Invite.joinGuild(req.user_id, code);
|
||||||
|
|
||||||
|
res.json(invite);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// * cant use permission of route() function because path doesn't have guild_id/channel_id
|
||||||
|
router.delete(
|
||||||
|
"/:code",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
body: "Invite",
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { code } = req.params;
|
||||||
|
const invite = await Invite.findOneOrFail({ where: { code } });
|
||||||
|
const { guild_id, channel_id } = invite;
|
||||||
|
|
||||||
|
const permission = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
guild_id,
|
||||||
|
channel_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!permission.has("MANAGE_GUILD") &&
|
||||||
|
!permission.has("MANAGE_CHANNELS")
|
||||||
|
)
|
||||||
|
throw new HTTPError(
|
||||||
|
"You missing the MANAGE_GUILD or MANAGE_CHANNELS permission",
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
Invite.delete({ code }),
|
||||||
|
emitEvent({
|
||||||
|
event: "INVITE_DELETE",
|
||||||
|
guild_id: guild_id,
|
||||||
|
data: {
|
||||||
|
channel_id: channel_id,
|
||||||
|
guild_id: guild_id,
|
||||||
|
code: code,
|
||||||
|
},
|
||||||
|
} as InviteDeleteEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ invite: invite });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
59
src/api/routes/oauth2/applications/@me.ts
Normal file
59
src/api/routes/oauth2/applications/@me.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
Application,
|
||||||
|
DiscordApiErrors,
|
||||||
|
PublicUserProjection,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "Application",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const app = await Application.findOneOrFail({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
relations: ["bot", "owner"],
|
||||||
|
select: {
|
||||||
|
owner: Object.fromEntries(
|
||||||
|
PublicUserProjection.map((x) => [x, true]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app.bot) throw DiscordApiErrors.BOT_ONLY_ENDPOINT;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...app,
|
||||||
|
owner: app.owner.toPublicUser(),
|
||||||
|
install_params:
|
||||||
|
app.install_params !== null ? app.install_params : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export default router;
|
252
src/api/routes/oauth2/authorize.ts
Normal file
252
src/api/routes/oauth2/authorize.ts
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
/*
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
Application,
|
||||||
|
ApplicationAuthorizeSchema,
|
||||||
|
DiscordApiErrors,
|
||||||
|
FieldErrors,
|
||||||
|
Member,
|
||||||
|
Permissions,
|
||||||
|
User,
|
||||||
|
getPermission,
|
||||||
|
} from "@valkyrie/util";
|
||||||
|
import { Request, Response, Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// TODO: scopes, other oauth types
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
query: {
|
||||||
|
client_id: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
// TODO: I really didn't feel like typing all of it out
|
||||||
|
200: {},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
// const { client_id, scope, response_type, redirect_url } = req.query;
|
||||||
|
const { client_id } = req.query;
|
||||||
|
if (!client_id) {
|
||||||
|
throw FieldErrors({
|
||||||
|
client_id: {
|
||||||
|
code: "BASE_TYPE_REQUIRED",
|
||||||
|
message: req.t("common:field.BASE_TYPE_REQUIRED"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await Application.findOne({
|
||||||
|
where: {
|
||||||
|
id: client_id as string,
|
||||||
|
},
|
||||||
|
relations: ["bot"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: use DiscordApiErrors
|
||||||
|
// findOneOrFail throws code 404
|
||||||
|
if (!app) throw DiscordApiErrors.UNKNOWN_APPLICATION;
|
||||||
|
if (!app.bot) throw DiscordApiErrors.OAUTH2_APPLICATION_BOT_ABSENT;
|
||||||
|
|
||||||
|
const bot = app.bot;
|
||||||
|
delete app.bot;
|
||||||
|
|
||||||
|
const user = await User.findOneOrFail({
|
||||||
|
where: {
|
||||||
|
id: req.user_id,
|
||||||
|
bot: false,
|
||||||
|
},
|
||||||
|
select: [
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"avatar",
|
||||||
|
"discriminator",
|
||||||
|
"public_flags",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const guilds = await Member.find({
|
||||||
|
where: {
|
||||||
|
user: {
|
||||||
|
id: req.user_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
relations: ["guild", "roles"],
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
// prettier-ignore
|
||||||
|
select: ["guild.id", "guild.name", "guild.icon", "guild.mfa_level", "guild.owner_id", "roles.id"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const guildsWithPermissions = guilds.map((x) => {
|
||||||
|
const perms =
|
||||||
|
x.guild.owner_id === user.id
|
||||||
|
? new Permissions(Permissions.FLAGS.ADMINISTRATOR)
|
||||||
|
: Permissions.finalPermission({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
roles: x.roles?.map((x) => x.id) || [],
|
||||||
|
},
|
||||||
|
guild: {
|
||||||
|
roles: x?.roles || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: x.guild.id,
|
||||||
|
name: x.guild.name,
|
||||||
|
icon: x.guild.icon,
|
||||||
|
mfa_level: x.guild.mfa_level,
|
||||||
|
permissions: perms.bitfield.toString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
guilds: guildsWithPermissions,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
avatar: user.avatar,
|
||||||
|
avatar_decoration: null, // TODO
|
||||||
|
discriminator: user.discriminator,
|
||||||
|
public_flags: user.public_flags,
|
||||||
|
},
|
||||||
|
application: {
|
||||||
|
id: app.id,
|
||||||
|
name: app.name,
|
||||||
|
icon: app.icon,
|
||||||
|
description: app.description,
|
||||||
|
summary: app.summary,
|
||||||
|
type: app.type,
|
||||||
|
hook: app.hook,
|
||||||
|
guild_id: null, // TODO support guilds
|
||||||
|
bot_public: app.bot_public,
|
||||||
|
bot_require_code_grant: app.bot_require_code_grant,
|
||||||
|
verify_key: app.verify_key,
|
||||||
|
flags: app.flags,
|
||||||
|
},
|
||||||
|
bot: {
|
||||||
|
id: bot.id,
|
||||||
|
username: bot.username,
|
||||||
|
avatar: bot.avatar,
|
||||||
|
avatar_decoration: null, // TODO
|
||||||
|
discriminator: bot.discriminator,
|
||||||
|
public_flags: bot.public_flags,
|
||||||
|
bot: true,
|
||||||
|
approximated_guild_count: 0, // TODO
|
||||||
|
},
|
||||||
|
authorized: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
route({
|
||||||
|
requestBody: "ApplicationAuthorizeSchema",
|
||||||
|
query: {
|
||||||
|
client_id: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
body: "OAuthAuthorizeResponse",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
body: "APIErrorResponse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as ApplicationAuthorizeSchema;
|
||||||
|
// const { client_id, scope, response_type, redirect_url } = req.query;
|
||||||
|
const { client_id } = req.query;
|
||||||
|
|
||||||
|
if (!client_id) {
|
||||||
|
throw FieldErrors({
|
||||||
|
client_id: {
|
||||||
|
code: "BASE_TYPE_REQUIRED",
|
||||||
|
message: req.t("common:field.BASE_TYPE_REQUIRED"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: ensure guild_id is not an empty string
|
||||||
|
// TODO: captcha verification
|
||||||
|
// TODO: MFA verification
|
||||||
|
|
||||||
|
const perms = await getPermission(
|
||||||
|
req.user_id,
|
||||||
|
body.guild_id,
|
||||||
|
undefined,
|
||||||
|
{ member_relations: ["user"] },
|
||||||
|
);
|
||||||
|
// getPermission cache won't exist if we're owner
|
||||||
|
if (
|
||||||
|
Object.keys(perms.cache || {}).length > 0 &&
|
||||||
|
perms.cache.member?.user.bot
|
||||||
|
)
|
||||||
|
throw DiscordApiErrors.UNAUTHORIZED;
|
||||||
|
perms.hasThrow("MANAGE_GUILD");
|
||||||
|
|
||||||
|
const app = await Application.findOne({
|
||||||
|
where: {
|
||||||
|
id: client_id as string,
|
||||||
|
},
|
||||||
|
relations: ["bot"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: use DiscordApiErrors
|
||||||
|
// findOneOrFail throws code 404
|
||||||
|
if (!app) throw new ApiError("Unknown Application", 10002, 404);
|
||||||
|
if (!app.bot)
|
||||||
|
throw new ApiError(
|
||||||
|
"OAuth2 application does not have a bot",
|
||||||
|
50010,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Member.addToGuild(app.id, body.guild_id);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
location: "/oauth2/authorized", // redirect URL
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
28
src/api/routes/oauth2/tokens.ts
Normal file
28
src/api/routes/oauth2/tokens.ts
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { route } from "@valkyrie/api";
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", route({}), async (req: Request, res: Response) => {
|
||||||
|
//TODO
|
||||||
|
res.json([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue