summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/middlewares/Authentication.ts13
-rw-r--r--src/routes/guilds/#guild_id/widget.json.ts142
-rw-r--r--src/routes/guilds/#guild_id/widget.png.ts108
-rw-r--r--src/routes/guilds/#guild_id/widget.ts39
-rw-r--r--src/schema/Widget.ts10
5 files changed, 306 insertions, 6 deletions
diff --git a/src/middlewares/Authentication.ts b/src/middlewares/Authentication.ts

index 630a45ff..b53632a8 100644 --- a/src/middlewares/Authentication.ts +++ b/src/middlewares/Authentication.ts
@@ -3,11 +3,12 @@ import { HTTPError } from "lambert-server"; import { checkToken, Config } from "@fosscord/server-util"; export const NO_AUTHORIZATION_ROUTES = [ - "/api/v8/auth/login", - "/api/v8/auth/register", - "/api/v8/webhooks/", - "/api/v8/gateway", - "/api/v8/experiments" + /^\/api\/v8\/auth\/login/, + /^\/api\/v8\/auth\/register/, + /^\/api\/v8\/webhooks\//, + /^\/api\/v8\/gateway/, + /^\/api\/v8\/experiments/, + /^\/api(\/v\d+)?\/guilds\/\d+\/widget\.(json|png)/ ]; declare global { @@ -22,7 +23,7 @@ declare global { export async function Authentication(req: Request, res: Response, next: NextFunction) { if (!req.url.startsWith("/api")) return next(); if (req.url.startsWith("/api/v8/invites") && req.method === "GET") return next(); - if (NO_AUTHORIZATION_ROUTES.some((x) => req.url.startsWith(x))) return next(); + if (NO_AUTHORIZATION_ROUTES.some((x) => x.test(req.url))) return next(); if (!req.headers.authorization) return next(new HTTPError("Missing Authorization Header", 401)); try { diff --git a/src/routes/guilds/#guild_id/widget.json.ts b/src/routes/guilds/#guild_id/widget.json.ts new file mode 100644
index 00000000..c1565e6d --- /dev/null +++ b/src/routes/guilds/#guild_id/widget.json.ts
@@ -0,0 +1,142 @@ +import { Request, Response, Router } from "express"; +import { Config, Permissions, GuildModel, InviteModel, ChannelModel, MemberModel } from "@fosscord/server-util"; +import { HTTPError } from "lambert-server"; +import { random } from "../../../util/RandomInviteID"; + +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("/", async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const guild = await GuildModel.findOne({ id: guild_id }).exec(); + if (!guild) throw new HTTPError("Guild does not exist", 404); + if (!guild.widget_enabled) throw new HTTPError("Widget Disabled", 404); + + // Fetch existing widget invite for widget channel + var invite = await InviteModel.findOne({ channel_id: guild.widget_channel_id, inviter_id: { $type: 10 } }).exec(); + 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()); + const body = { + 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, + inviter_id: null + }; + + invite = await new InviteModel(body).save(); + } + + // Fetch voice channels, and the @everyone permissions object + let channels: any[] = []; + await ChannelModel.find( + { guild_id: guild_id, type: 2 }, + { permission_overwrites: { $elemMatch: { id: guild_id } } } + ).lean() + .select("id name position permission_overwrites") + .sort({ position: 1 }) + .cursor() + .eachAsync(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, + position: doc.position + } + ) + } + }); + + // Fetch members + // TODO: Understand how Discord's max 100 random member sample works, and apply to here (see top of this file) + let members: any[] = []; + await MemberModel.find({ guild_id: guild_id }) + .lean() + .populate({ path: "user", select: { _id: 0, username: 1, avatar: 1, presence: 1 } }) + .select("id user nick deaf mute") + .cursor() + .eachAsync(doc => { + const status = doc.user?.presence.status || "offline"; + if (status != "offline") { + let item = {} + + item = { + ...item, + id: null, // this is updated during the sort outside of the query + username: doc.nick || doc.user?.username, + discriminator: "0000", // intended (https://github.com/discord/discord-api-docs/issues/1287) + avatar: null, // intended, avatar_url below will return a unique guild + user url to the avatar + status: status + } + + const activity = doc.user?.presence.activities[0]; + if (activity) { + item = { + ...item, + game: { name: activity.name } + } + } + + // TODO: If the member is in a voice channel, return extra widget details + // Extra fields returned include deaf, mute, self_deaf, self_mute, supress, and channel_id (voice channel connected to) + // Get this from VoiceState + + + // TODO: Implement a widget-avatar endpoint on the CDN, and implement logic here to request it + // Get unique avatar url for guild user, cdn to serve the actual avatar image on this url + /* + const avatar = doc.user?.avatar; + if (avatar) { + const CDN_HOST = Config.get().cdn.endpoint || "http://localhost:3003"; + const avatar_url = "/widget-avatars/" + ; + item = { + ...item, + avatar_url: avatar_url + } + } + */ + + members.push(item); + } + }); + + // Sort members, and update ids (Unable to do under the mongoose query due to https://mongoosejs.com/docs/faq.html#populate_sort_order) + members = members.sort((first, second) => 0 - (first.username > second.username ? -1 : 1)); + members.forEach((x, i) => { + x.id = i; + }); + + // 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; diff --git a/src/routes/guilds/#guild_id/widget.png.ts b/src/routes/guilds/#guild_id/widget.png.ts new file mode 100644
index 00000000..986cb05a --- /dev/null +++ b/src/routes/guilds/#guild_id/widget.png.ts
@@ -0,0 +1,108 @@ +import { Request, Response, Router } from "express"; +import { GuildModel } from "@fosscord/server-util"; +import { HTTPError } from "lambert-server"; +import { Image } from "canvas"; +import fs from "fs"; + +const router: Router = Router(); + +// https://discord.com/developers/docs/resources/guild#get-guild-widget-image +// TODO: Cache the response +router.get("/", async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const guild = await GuildModel.findOne({ id: guild_id }).exec(); + if (!guild) throw new HTTPError("Guild does not exist", 404); + if (!guild.widget_enabled) throw new HTTPError("Unknown Guild", 404); + + // Fetch guild information + const icon = 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 } = require("canvas"); + const { loadImage } = require("canvas"); + const sizeOf = require("image-size"); + + // TODO: Widget style templates need Fosscord branding + const source = __dirname + "../../../../../cache/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 img = new Image(); + img.src = 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; diff --git a/src/routes/guilds/#guild_id/widget.ts b/src/routes/guilds/#guild_id/widget.ts new file mode 100644
index 00000000..d8468da2 --- /dev/null +++ b/src/routes/guilds/#guild_id/widget.ts
@@ -0,0 +1,39 @@ +import { Request, Response, Router } from "express"; +import { getPermission, GuildModel } from "@fosscord/server-util"; +import { HTTPError } from "lambert-server"; +import { check } from "../../../util/instanceOf"; +import { WidgetModifySchema } from "../../../schema/Widget"; + +const router: Router = Router(); + +// https://discord.com/developers/docs/resources/guild#get-guild-widget-settings +router.get("/", async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const perms = await getPermission(req.user_id, guild_id); + perms.hasThrow("MANAGE_GUILD"); + + const guild = await GuildModel.findOne({ id: guild_id }).exec(); + if (!guild) throw new HTTPError("Guild not found", 404); + + 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("/", check(WidgetModifySchema), async (req: Request, res: Response) => { + const body = req.body as WidgetModifySchema; + const { guild_id } = req.params; + + const perms = await getPermission(req.user_id, guild_id); + perms.hasThrow("MANAGE_GUILD"); + + const guild = await GuildModel.findOne({ id: guild_id }).exec(); + if (!guild) throw new HTTPError("Guild not found", 404); + + await GuildModel.updateOne({ id: guild_id }, { widget_enabled: body['enabled'], widget_channel_id: body['channel_id'] }).exec(); + // 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; diff --git a/src/schema/Widget.ts b/src/schema/Widget.ts new file mode 100644
index 00000000..6a15a139 --- /dev/null +++ b/src/schema/Widget.ts
@@ -0,0 +1,10 @@ +// https://discord.com/developers/docs/resources/guild#guild-widget-object +export const WidgetModifySchema = { + $enabled: Boolean, // whether the widget is enabled + $channel_id: String // the widget channel id +}; + +export interface WidgetModifySchema { + enabled: boolean; // whether the widget is enabled + channel_id: string; // the widget channel id +}