From 7929c9ac9af674fbf4411bf663873ba487a907c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Susa=C3=B1a?= Date: Fri, 5 Apr 2024 23:02:33 -0400 Subject: [PATCH] feat: proxy cdn Co-authored-by: MARCROCK22 --- src/api/CDN.ts | 325 ------------------------------ src/api/Router.ts | 35 +++- src/api/Routes/cdn.ts | 42 ++-- src/api/api.ts | 5 +- src/api/index.ts | 1 - src/api/utils/constants.ts | 6 +- src/common/types/options.ts | 4 +- src/structures/GuildEmoji.ts | 6 +- src/structures/GuildMember.ts | 2 +- src/structures/User.ts | 11 +- src/structures/Webhook.ts | 2 +- src/structures/extra/BaseGuild.ts | 6 +- 12 files changed, 77 insertions(+), 368 deletions(-) delete mode 100644 src/api/CDN.ts diff --git a/src/api/CDN.ts b/src/api/CDN.ts deleted file mode 100644 index 63dd4a7..0000000 --- a/src/api/CDN.ts +++ /dev/null @@ -1,325 +0,0 @@ -/* eslint-disable jsdoc/check-param-names */ -import { CDN_URL } from '../common/index.js'; -import { - ALLOWED_EXTENSIONS, - ALLOWED_SIZES, - ALLOWED_STICKER_EXTENSIONS, - type ImageExtension, - type ImageSize, - type StickerExtension, -} from './utils/constants.js'; - -/** - * The options used for image URLs - */ -export interface BaseImageURLOptions { - /** - * The extension to use for the image URL - * - * @defaultValue `'webp'` - */ - extension?: ImageExtension; - /** - * The size specified in the image URL - */ - size?: ImageSize; -} - -/** - * The options used for image URLs with animated content - */ -export interface ImageURLOptions extends BaseImageURLOptions { - /** - * Whether or not to prefer the static version of an image asset. - */ - forceStatic?: boolean; -} - -/** - * The options to use when making a CDN URL - */ -export interface MakeURLOptions { - /** - * The allowed extensions that can be used - */ - allowedExtensions?: readonly string[]; - /** - * The extension to use for the image URL - * - * @defaultValue `'webp'` - */ - extension?: ImageExtension | StickerExtension | undefined; - /** - * The size specified in the image URL - */ - size?: ImageSize; -} - -/** - * The CDN link builder - */ -export class CDN { - public constructor(private readonly base: string = CDN_URL) {} - - /** - * Generates an app asset URL for a client's asset. - * - * @param clientId - The client id that has the asset - * @param assetHash - The hash provided by Discord for this asset - * @param options - Optional options for the asset - */ - public appAsset(clientId: string, assetHash: string, options?: Readonly): string { - return this.makeURL(`/app-assets/${clientId}/${assetHash}`, options); - } - - /** - * Generates an app icon URL for a client's icon. - * - * @param clientId - The client id that has the icon - * @param iconHash - The hash provided by Discord for this icon - * @param options - Optional options for the icon - */ - public appIcon(clientId: string, iconHash: string, options?: Readonly): string { - return this.makeURL(`/app-icons/${clientId}/${iconHash}`, options); - } - - /** - * Generates an avatar URL, e.g. for a user or a webhook. - * - * @param id - The id that has the icon - * @param avatarHash - The hash provided by Discord for this avatar - * @param options - Optional options for the avatar - */ - public avatar(id: string, avatarHash: string, options?: Readonly): string { - return this.dynamicMakeURL(`/avatars/${id}/${avatarHash}`, avatarHash, options); - } - - /** - * Generates a user avatar decoration URL. - * - * @param userId - The id of the user - * @param userAvatarDecoration - The hash provided by Discord for this avatar decoration - * @param options - Optional options for the avatar decoration - */ - public avatarDecoration( - userId: string, - userAvatarDecoration: string, - options?: Readonly, - ): string { - return this.makeURL(`/avatar-decorations/${userId}/${userAvatarDecoration}`, options); - } - - /** - * Generates a banner URL, e.g. for a user or a guild. - * - * @param id - The id that has the banner splash - * @param bannerHash - The hash provided by Discord for this banner - * @param options - Optional options for the banner - */ - public banner(id: string, bannerHash: string, options?: Readonly): string { - return this.dynamicMakeURL(`/banners/${id}/${bannerHash}`, bannerHash, options); - } - - /** - * Generates an icon URL for a channel, e.g. a group DM. - * - * @param channelId - The channel id that has the icon - * @param iconHash - The hash provided by Discord for this channel - * @param options - Optional options for the icon - */ - public channelIcon(channelId: string, iconHash: string, options?: Readonly): string { - return this.makeURL(`/channel-icons/${channelId}/${iconHash}`, options); - } - - /** - * Generates a default avatar URL - * - * @param index - The default avatar index - * @remarks - * To calculate the index for a user do `(userId >> 22) % 6`, - * or `discriminator % 5` if they're using the legacy username system. - */ - public defaultAvatar(index: number): string { - return this.makeURL(`/embed/avatars/${index}`, { extension: 'png' }); - } - - /** - * Generates a discovery splash URL for a guild's discovery splash. - * - * @param guildId - The guild id that has the discovery splash - * @param splashHash - The hash provided by Discord for this splash - * @param options - Optional options for the splash - */ - public discoverySplash(guildId: string, splashHash: string, options?: Readonly): string { - return this.makeURL(`/discovery-splashes/${guildId}/${splashHash}`, options); - } - - /** - * Generates an emoji's URL for an emoji. - * - * @param emojiId - The emoji id - * @param options - Optional options for the emoji - */ - public emoji(emojiId: string, options?: Readonly): string { - const resolvedOptions = options; - - return this.makeURL(`/emojis/${emojiId}`, resolvedOptions); - } - - /** - * Generates a guild member avatar URL. - * - * @param guildId - The id of the guild - * @param userId - The id of the user - * @param avatarHash - The hash provided by Discord for this avatar - * @param options - Optional options for the avatar - */ - public guildMemberAvatar( - guildId: string, - userId: string, - avatarHash: string, - options?: Readonly, - ): string { - return this.dynamicMakeURL(`/guilds/${guildId}/users/${userId}/avatars/${avatarHash}`, avatarHash, options); - } - - /** - * Generates a guild member banner URL. - * - * @param guildId - The id of the guild - * @param userId - The id of the user - * @param bannerHash - The hash provided by Discord for this banner - * @param options - Optional options for the banner - */ - public guildMemberBanner( - guildId: string, - userId: string, - bannerHash: string, - options?: Readonly, - ): string { - return this.dynamicMakeURL(`/guilds/${guildId}/users/${userId}/banner`, bannerHash, options); - } - - /** - * Generates an icon URL, e.g. for a guild. - * - * @param id - The id that has the icon splash - * @param iconHash - The hash provided by Discord for this icon - * @param options - Optional options for the icon - */ - public icon(id: string, iconHash: string, options?: Readonly): string { - return this.dynamicMakeURL(`/icons/${id}/${iconHash}`, iconHash, options); - } - - /** - * Generates a URL for the icon of a role - * - * @param roleId - The id of the role that has the icon - * @param roleIconHash - The hash provided by Discord for this role icon - * @param options - Optional options for the role icon - */ - public roleIcon(roleId: string, roleIconHash: string, options?: Readonly): string { - return this.makeURL(`/role-icons/${roleId}/${roleIconHash}`, options); - } - - /** - * Generates a guild invite splash URL for a guild's invite splash. - * - * @param guildId - The guild id that has the invite splash - * @param splashHash - The hash provided by Discord for this splash - * @param options - Optional options for the splash - */ - public splash(guildId: string, splashHash: string, options?: Readonly): string { - return this.makeURL(`/splashes/${guildId}/${splashHash}`, options); - } - - /** - * Generates a sticker URL. - * - * @param stickerId - The sticker id - * @param extension - The extension of the sticker - * @privateRemarks - * Stickers cannot have a `.webp` extension, so we default to a `.png` - */ - public sticker(stickerId: string, extension: StickerExtension = 'png'): string { - return this.makeURL(`/stickers/${stickerId}`, { allowedExtensions: ALLOWED_STICKER_EXTENSIONS, extension }); - } - - /** - * Generates a sticker pack banner URL. - * - * @param bannerId - The banner id - * @param options - Optional options for the banner - */ - public stickerPackBanner(bannerId: string, options?: Readonly): string { - return this.makeURL(`/app-assets/710982414301790216/store/${bannerId}`, options); - } - - /** - * Generates a team icon URL for a team's icon. - * - * @param teamId - The team id that has the icon - * @param iconHash - The hash provided by Discord for this icon - * @param options - Optional options for the icon - */ - public teamIcon(teamId: string, iconHash: string, options?: Readonly): string { - return this.makeURL(`/team-icons/${teamId}/${iconHash}`, options); - } - - /** - * Generates a cover image for a guild scheduled event. - * - * @param scheduledEventId - The scheduled event id - * @param coverHash - The hash provided by discord for this cover image - * @param options - Optional options for the cover image - */ - public guildScheduledEventCover( - scheduledEventId: string, - coverHash: string, - options?: Readonly, - ): string { - return this.makeURL(`/guild-events/${scheduledEventId}/${coverHash}`, options); - } - - /** - * Constructs the URL for the resource, checking whether or not `hash` starts with `a_` if `dynamic` is set to `true`. - * - * @param route - The base cdn route - * @param hash - The hash provided by Discord for this icon - * @param options - Optional options for the link - */ - private dynamicMakeURL( - route: string, - hash: string, - { forceStatic = false, ...options }: Readonly = {}, - ): string { - return this.makeURL(route, !forceStatic && hash.startsWith('a_') ? { ...options, extension: 'gif' } : options); - } - - /** - * Constructs the URL for the resource - * - * @param route - The base cdn route - * @param options - The extension/size options for the link - */ - private makeURL( - route: string, - { allowedExtensions = ALLOWED_EXTENSIONS, extension = 'webp', size }: Readonly = {}, - ): string { - if (!allowedExtensions.includes(extension)) { - throw new RangeError(`Invalid extension provided: ${extension}\nMust be one of: ${allowedExtensions.join(', ')}`); - } - - if (size && !ALLOWED_SIZES.includes(size)) { - throw new RangeError(`Invalid size provided: ${size}\nMust be one of: ${ALLOWED_SIZES.join(', ')}`); - } - - const url = new URL(`${this.base}${route}.${extension}`); - - if (size) { - url.searchParams.set('size', String(size)); - } - - return url.toString(); - } -} diff --git a/src/api/Router.ts b/src/api/Router.ts index bd928b5..be4bc31 100644 --- a/src/api/Router.ts +++ b/src/api/Router.ts @@ -1,6 +1,6 @@ import { CDN_URL } from '../common'; import type { APIRoutes, ApiHandler, CDNRoute } from './index'; -import type { HttpMethods } from './shared'; +import type { HttpMethods, ImageExtension, ImageSize, StickerExtension } from './shared'; export enum ProxyRequestMethod { Delete = 'delete', @@ -43,15 +43,15 @@ export const CDNRouter = { return new Proxy(noop, { get: (_, key: string) => { if (key === 'get') { - return (value?: string) => { + return (value: string | undefined, options?: CDNUrlOptions) => { const lastRoute = `${CDN_URL}/${route.join('/')}`; - if (value) { - if (typeof value !== 'string') { - value = String(value); - } - return `${lastRoute}/${value}`; + let routeResult = lastRoute; + if (value && typeof value === 'string') { + routeResult = `${lastRoute}/${value}`; + return parseCDNURL(routeResult, options); } - return lastRoute; + // @ts-expect-error + return parseCDNURL(routeResult, value); }; } return this.createProxy([...route, key]); @@ -62,3 +62,22 @@ export const CDNRouter = { }) as unknown as CDNRoute; }, }; + +export interface BaseCDNUrlOptions { + extension?: ImageExtension | StickerExtension | undefined; + size?: ImageSize; +} + +export interface CDNUrlOptions extends BaseCDNUrlOptions { + forceStatic?: boolean; +} + +export function parseCDNURL(route: string, options: CDNUrlOptions = { forceStatic: false }) { + if (options.forceStatic && route.includes('a_')) options.extension = 'png'; + + const url = new URL(`${route}.${options.extension}`); + + if (options.size) url.searchParams.set('size', `${options.size}`); + + return url.toString(); +} diff --git a/src/api/Routes/cdn.ts b/src/api/Routes/cdn.ts index 7092954..62448ca 100644 --- a/src/api/Routes/cdn.ts +++ b/src/api/Routes/cdn.ts @@ -1,3 +1,6 @@ +import type { BaseCDNUrlOptions, CDNUrlOptions } from '../..'; +import type { StickerExtension } from '../shared'; + export interface CDNRoute { embed: { avatars: { @@ -5,62 +8,71 @@ export interface CDNRoute { }; }; avatars(id: string): { - get(hash: string): string; + get(hash: string, options?: CDNUrlOptions): string; + }; + 'avatar-decorations'(userId: string): { + get(hash: string, options?: BaseCDNUrlOptions): string; + }; + 'channel-icons'(channelId: string): { + get(hash: string, options?: BaseCDNUrlOptions): string; }; icons(guildId: string): { - get(hash: string): string; + get(hash: string, options?: CDNUrlOptions): string; }; splashes(guildId: string): { - get(hash: string): string; + get(hash: string, options?: BaseCDNUrlOptions): string; }; 'discovery-splashes'(guidId: string): { - get(hash: string): string; + get(hash: string, options?: BaseCDNUrlOptions): string; }; banners(id: string): { - get(hash: string): string; + get(hash: string, options?: CDNUrlOptions): string; }; guilds(id: string): { users(id: string): { avatars(hash: string): { - get(): string; + get(options?: CDNUrlOptions): string; }; banners(hash: string): { - get(): string; + get(options?: CDNUrlOptions): string; }; }; }; + 'guild-events'(eventId: string): { + get(hash: string, options?: BaseCDNUrlOptions): string; + }; emojis(id: string): { - get(): string; + get(options?: BaseCDNUrlOptions): string; }; appIcons(appId: string): { - get(iconOrCover: string): string; + get(iconOrCover: string, options?: BaseCDNUrlOptions): string; }; 'app-assets'(appId: string): { get(asset: string): string; achievements(id: string): { icons(hash: string): { - get(): string; + get(options?: BaseCDNUrlOptions): string; }; }; }; 'team-icons'(teamId: string): { - get(hash: string): string; + get(hash: string, options?: BaseCDNUrlOptions): string; }; stickers(id: string): { - get(): string; + get(extension: StickerExtension): string; }; 'role-icons'(roleId: string): { - get(icon: string): string; + get(icon: string, options?: BaseCDNUrlOptions): string; }; 'guild-events'(id: string): { - get(cover: string): string; + get(cover: string, options?: BaseCDNUrlOptions): string; }; } export interface CDNRoute { 'app-assets'(id: '710982414301790216'): { store(packBannerId: string): { - get(): string; + get(options?: BaseCDNUrlOptions): string; }; }; } diff --git a/src/api/api.ts b/src/api/api.ts index f14ac14..bb4fbe6 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -6,8 +6,7 @@ import { Logger } from '../common'; import { snowflakeToTimestamp } from '../structures/extra/functions'; import type { WorkerData } from '../websocket'; import type { WorkerSendApiRequest } from '../websocket/discord/worker'; -import { CDN } from './CDN'; -import type { ProxyRequestMethod } from './Router'; +import { CDNRouter, type ProxyRequestMethod } from './Router'; import { Bucket } from './bucket'; import { DefaultUserAgent, @@ -26,7 +25,7 @@ export class ApiHandler { globalBlock = false; ratelimits = new Map(); readyQueue: (() => void)[] = []; - cdn = new CDN(); + cdn = CDNRouter.createProxy(); debugger?: Logger; workerPromises?: Map any; reject: (error: any) => any }>; diff --git a/src/api/index.ts b/src/api/index.ts index 40f8800..d4c28b0 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,3 @@ -export * from './CDN'; export * from './Router'; export * from './Routes'; export * from './api'; diff --git a/src/api/utils/constants.ts b/src/api/utils/constants.ts index 3a68e3a..214d7b3 100644 --- a/src/api/utils/constants.ts +++ b/src/api/utils/constants.ts @@ -1,7 +1,7 @@ export const DefaultUserAgent = 'DiscordBot (https://seyfert.dev)'; -export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const satisfies readonly string[]; -export const ALLOWED_STICKER_EXTENSIONS = ['png', 'json', 'gif'] as const satisfies readonly string[]; -export const ALLOWED_SIZES = [16, 32, 64, 128, 256, 512, 1_024, 2_048, 4_096] as const satisfies readonly number[]; +export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const; +export const ALLOWED_STICKER_EXTENSIONS = ['png', 'json', 'gif'] as const; +export const ALLOWED_SIZES = [16, 32, 64, 128, 256, 512, 1_024, 2_048, 4_096] as const; export type ImageExtension = (typeof ALLOWED_EXTENSIONS)[number]; export type StickerExtension = (typeof ALLOWED_STICKER_EXTENSIONS)[number]; diff --git a/src/common/types/options.ts b/src/common/types/options.ts index 86bdd99..a53dcea 100644 --- a/src/common/types/options.ts +++ b/src/common/types/options.ts @@ -1,8 +1,8 @@ import type { Identify } from '..'; -import type { ImageURLOptions } from '../../api'; +import type { CDNUrlOptions } from '../../api'; import type { UsingClient } from '../../commands'; -export type ImageOptions = ImageURLOptions; +export type ImageOptions = CDNUrlOptions; export type MethodContext = Identify<{ client: UsingClient } & T>; diff --git a/src/structures/GuildEmoji.ts b/src/structures/GuildEmoji.ts index 4024a79..d2dd2e0 100644 --- a/src/structures/GuildEmoji.ts +++ b/src/structures/GuildEmoji.ts @@ -1,5 +1,5 @@ import type { APIEmoji, RESTPatchAPIChannelJSONBody, RESTPatchAPIGuildEmojiJSONBody } from 'discord-api-types/v10'; -import type { BaseImageURLOptions } from '../api'; +import type { BaseCDNUrlOptions } from '../api'; import type { UsingClient } from '../commands'; import type { EmojiShorter, MethodContext, ObjectToLower } from '../common'; import { DiscordBase } from './extra/DiscordBase'; @@ -32,8 +32,8 @@ export class GuildEmoji extends DiscordBase { return this.client.emojis.fetch(this.guildId, this.id, force); } - url(options?: BaseImageURLOptions) { - return this.rest.cdn.emoji(this.id, options); + url(options?: BaseCDNUrlOptions) { + return this.rest.cdn.emojis(this.id).get(options); } toString() { diff --git a/src/structures/GuildMember.ts b/src/structures/GuildMember.ts index 34bbc44..cb2bfaf 100644 --- a/src/structures/GuildMember.ts +++ b/src/structures/GuildMember.ts @@ -185,7 +185,7 @@ export class GuildMember extends BaseGuildMember { return this.user.avatarURL(options); } - return this.rest.cdn.guildMemberAvatar(this.guildId, this.id, this.avatar, options); + return this.rest.cdn.guilds(this.guildId).users(this.id).avatars(this.avatar).get(options); } bannerURL(options?: ImageOptions) { diff --git a/src/structures/User.ts b/src/structures/User.ts index 3711f42..b254920 100644 --- a/src/structures/User.ts +++ b/src/structures/User.ts @@ -36,14 +36,19 @@ export class User extends DiscordBase { if (!this.avatar) { const avatarIndex = this.discriminator === '0' ? Number(BigInt(this.id) >> 22n) % 6 : Number.parseInt(this.discriminator) % 5; - return this.rest.cdn.defaultAvatar(avatarIndex); + return this.rest.cdn.embed.avatars.get(avatarIndex); } - return this.rest.cdn.avatar(this.id, this.avatar, options); + return this.rest.cdn.avatars(this.id).get(this.avatar, options); + } + + avatarDecorationURL(options?: ImageOptions) { + if (!this.avatarDecoration) return; + return this.rest.cdn['avatar-decorations'](this.id).get(this.avatarDecoration, options); } bannerURL(options?: ImageOptions) { if (!this.banner) return; - return this.rest.cdn.banner(this.id, this.banner, options); + return this.rest.cdn.banners(this.id).get(this.banner, options); } presence() { diff --git a/src/structures/Webhook.ts b/src/structures/Webhook.ts index 246f70e..dfb5526 100644 --- a/src/structures/Webhook.ts +++ b/src/structures/Webhook.ts @@ -84,7 +84,7 @@ export class Webhook extends DiscordBase { return null; } - return this.rest.cdn.avatar(this.id, this.avatar, options); + return this.rest.cdn.avatars(this.id).get(this.avatar, options); } /** diff --git a/src/structures/extra/BaseGuild.ts b/src/structures/extra/BaseGuild.ts index 2e17df1..8bc2c33 100644 --- a/src/structures/extra/BaseGuild.ts +++ b/src/structures/extra/BaseGuild.ts @@ -42,7 +42,7 @@ export class BaseGuild extends DiscordBase { if (!this.icon) { return; } - return this.rest.cdn.icon(this.id, this.icon, options); + return this.rest.cdn.icons(this.id).get(this.icon, options); } /** @@ -55,7 +55,7 @@ export class BaseGuild extends DiscordBase { if (!this.splash) { return; } - return this.rest.cdn.discoverySplash(this.id, this.splash, options); + return this.rest.cdn['discovery-splashes'](this.id).get(this.splash, options); } /** @@ -68,7 +68,7 @@ export class BaseGuild extends DiscordBase { if (!this.banner) { return; } - return this.rest.cdn.banner(this.id, this.banner, options); + return this.rest.cdn.banners(this.id).get(this.banner, options); } toString(): string {