Merge branch 'master' into fix/widget.json-channel-ordering-deleted-channels

This commit is contained in:
Madeline
2024-08-22 09:53:34 +10:00
committed by GitHub
27 changed files with 370 additions and 124 deletions

View File

@@ -34,7 +34,7 @@ import "missing-native-js-functions";
import morgan from "morgan";
import path from "path";
import { red } from "picocolors";
import { Authentication, CORS } from "./middlewares/";
import { Authentication, CORS, ImageProxy } from "./middlewares/";
import { BodyParser } from "./middlewares/BodyParser";
import { ErrorHandler } from "./middlewares/ErrorHandler";
import { initRateLimits } from "./middlewares/RateLimit";
@@ -137,6 +137,8 @@ export class SpacebarServer extends Server {
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")),
);

View File

@@ -0,0 +1,180 @@
/*
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, JimpType } from "@spacebar/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("Invalid signature, expected " + hash + " got " + path[0]);
res.status(403).send("Invalid signature");
return;
}
const abort = new AbortController();
setTimeout(() => abort.abort(), 5000);
const request = await fetch(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);
}

View File

@@ -21,3 +21,4 @@ export * from "./BodyParser";
export * from "./CORS";
export * from "./ErrorHandler";
export * from "./RateLimit";
export * from "./ImageProxy";

View File

@@ -1,31 +1,24 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { getIpAdress, route, verifyCaptcha } from "@spacebar/api";
import {
Config,
Email,
FieldErrors,
ForgotPasswordSchema,
User,
} from "@spacebar/util";
import { Config, Email, ForgotPasswordSchema, User } from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
const router = Router();
router.post(
@@ -37,9 +30,6 @@ router.post(
400: {
body: "APIErrorOrCaptchaResponse",
},
500: {
body: "APIErrorResponse",
},
},
}),
async (req: Request, res: Response) => {
@@ -71,50 +61,20 @@ router.post(
}
}
const user = await User.findOneOrFail({
res.sendStatus(204);
const user = await User.findOne({
where: [{ phone: login }, { email: login }],
select: ["username", "id", "disabled", "deleted", "email"],
relations: ["security_keys"],
}).catch(() => {
throw FieldErrors({
login: {
message: req.t("auth:password_reset.EMAIL_DOES_NOT_EXIST"),
code: "EMAIL_DOES_NOT_EXIST",
},
});
});
select: ["username", "id", "email"],
}).catch(() => {});
if (!user.email)
throw FieldErrors({
login: {
message:
"This account does not have an email address associated with it.",
code: "NO_EMAIL",
},
});
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,
});
return await Email.sendResetPassword(user, user.email)
.then(() => {
return res.sendStatus(204);
})
.catch((e) => {
if (user && user.email) {
Email.sendResetPassword(user, user.email).catch((e) => {
console.error(
`Failed to send password reset email to ${user.username}#${user.discriminator}: ${e}`,
`Failed to send password reset email to ${user.username}#${user.discriminator} (${user.id}): ${e}`,
);
throw new HTTPError("Failed to send password reset email", 500);
});
}
},
);

View File

@@ -23,7 +23,9 @@ import {
DiscordApiErrors,
emitEvent,
Message,
MessageCreateEvent,
MessageUpdateEvent,
User,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
@@ -61,6 +63,30 @@ router.put(
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({
@@ -77,6 +103,12 @@ router.put(
last_pin_timestamp: undefined,
},
} as ChannelPinsUpdateEvent),
systemPinMessage.save(),
emitEvent({
event: "MESSAGE_CREATE",
channel_id: message.channel_id,
data: systemPinMessage,
} as MessageCreateEvent),
]);
res.sendStatus(204);

View File

@@ -17,9 +17,15 @@
*/
import { random, route } from "@spacebar/api";
import { Channel, Guild, Invite, Member, Permissions } from "@spacebar/util";
import {
Channel,
DiscordApiErrors,
Guild,
Invite,
Member,
Permissions,
} from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
const router: Router = Router();
@@ -46,14 +52,14 @@ router.get(
}),
async (req: Request, res: Response) => {
const { guild_id } = req.params;
const guild = await Guild.findOneOrFail({
where: { id: guild_id },
select: {
channel_ordering: true,
},
});
if (!guild.widget_enabled) throw new HTTPError("Widget Disabled", 404);
if (!guild.widget_enabled) throw DiscordApiErrors.EMBED_DISABLED;
// Fetch existing widget invite for widget channel
let invite = await Invite.findOne({

View File

@@ -1,17 +1,17 @@
/*
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/>.
*/
@@ -19,11 +19,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { route } from "@spacebar/api";
import { Guild } from "@spacebar/util";
import { DiscordApiErrors, Guild } from "@spacebar/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();
@@ -48,10 +49,10 @@ router.get(
const { guild_id } = req.params;
const guild = await Guild.findOneOrFail({ where: { id: guild_id } });
if (!guild.widget_enabled) throw new HTTPError("Unknown Guild", 404);
if (!guild.widget_enabled) throw DiscordApiErrors.EMBED_DISABLED;
// Fetch guild information
const icon = guild.icon;
const icon = "avatars/" + guild_id + "/" + guild.icon;
const name = guild.name;
const presence = guild.presence_count + " ONLINE";
@@ -69,8 +70,7 @@ router.get(
}
// Setup canvas
const { createCanvas } = require("canvas");
const { loadImage } = require("canvas");
const { createCanvas, loadImage } = require("canvas");
const sizeOf = require("image-size");
// TODO: Widget style templates need Spacebar branding
@@ -211,8 +211,8 @@ async function drawIcon(
scale: number,
icon: string,
) {
const img = new (require("canvas").Image)();
img.src = icon;
const { loadImage } = require("canvas");
const img = await loadImage(await storage.get(icon));
// Do some canvas clipping magic!
canvas.save();