From 0910965de54c21738d314d257cefdb4afcdc4cdd Mon Sep 17 00:00:00 2001 From: Danny May Date: Sun, 20 Nov 2022 22:50:50 +0000 Subject: [PATCH] Fix sending files through a rest proxy (#2593) * Fix rest proxy not working with files * Fix some credits * Add tests * Fix test * Remove some usage of any * Fix mime matching * Fix formatting issues --- rest/createRequestBody.ts | 57 +++++--- rest/runMethod.ts | 2 +- tests/utils.test.ts | 32 +++++ util/base64.ts | 274 ++++++++++++++++++++++++++++++++++++++ util/urlToBase64.ts | 106 +-------------- 5 files changed, 351 insertions(+), 120 deletions(-) create mode 100644 util/base64.ts diff --git a/rest/createRequestBody.ts b/rest/createRequestBody.ts index 07b7e6a7a..042868f7d 100644 --- a/rest/createRequestBody.ts +++ b/rest/createRequestBody.ts @@ -1,7 +1,8 @@ -import { RestManager } from "./restManager.ts"; import { FileContent } from "../types/discordeno.ts"; +import { decode } from "../util/base64.ts"; import { USER_AGENT } from "../util/constants.ts"; import { RequestMethod } from "./rest.ts"; +import { RestManager } from "./restManager.ts"; /** Creates the request body and headers that are necessary to send a request. Will handle different types of methods and everything necessary for discord. */ export function createRequestBody(rest: RestManager, options: CreateRequestBodyOptions) { @@ -31,29 +32,19 @@ export function createRequestBody(rest: RestManager, options: CreateRequestBodyO // IF A FILE/ATTACHMENT IS PRESENT WE NEED SPECIAL HANDLING if (options.body?.file) { - if (!Array.isArray(options.body.file)) { - options.body.file = [options.body.file]; - } + const files = findFiles(options.body.file); const form = new FormData(); // WHEN CREATING A STICKER, DISCORD WANTS FORM DATA ONLY if (options.url?.endsWith("/stickers") && options.method === "POST") { - form.append( - `file`, - (options.body.file as FileContent[])[0].blob, - (options.body.file as FileContent[])[0].name, - ); + form.append(`file`, files[0].blob, files[0].name); form.append(`name`, options.body.name as string); form.append(`description`, options.body.description as string); form.append(`tags`, options.body.tags as string); } else { - for (let i = 0; i < (options.body.file as FileContent[]).length; i++) { - form.append( - `file${i}`, - (options.body.file as FileContent[])[i].blob, - (options.body.file as FileContent[])[i].name, - ); + for (let i = 0; i < files.length; i++) { + form.append(`file${i}`, files[i].blob, files[i].name); } form.append("payload_json", JSON.stringify({ ...options.body, file: undefined })); @@ -78,3 +69,39 @@ export interface CreateRequestBodyOptions { unauthorized?: boolean; url?: string; } + +function findFiles(file: unknown): FileContent[] { + if (!file) { + return []; + } + + const files: unknown[] = Array.isArray(file) ? file : [file]; + return files.filter(coerceToFileContent); +} + +function coerceToFileContent(value: unknown): value is FileContent { + if (!value || typeof value !== "object") { + return false; + } + + const file = value as Record; + if (typeof file.name !== "string") { + return false; + } + + switch (typeof file.blob) { + case "string": { + const match = file.blob.match(/^data:(?[a-zA-Z0-9\/]*);base64,(?.*)$/); + if (match?.groups === undefined) { + return false; + } + const { mimeType, content } = match.groups; + file.blob = new Blob([decode(content)], { type: mimeType }); + return true; + } + case "object": + return file.blob instanceof Blob; + default: + return false; + } +} diff --git a/rest/runMethod.ts b/rest/runMethod.ts index 99de072ea..3be66abec 100644 --- a/rest/runMethod.ts +++ b/rest/runMethod.ts @@ -1,6 +1,6 @@ import { FileContent } from "../mod.ts"; import { API_VERSION, BASE_URL, baseEndpoints } from "../util/constants.ts"; -import { encode } from "../util/urlToBase64.ts"; +import { encode } from "../util/base64.ts"; import { RequestMethod, RestRequestRejection, RestRequestResponse } from "./rest.ts"; import { RestManager } from "./restManager.ts"; diff --git a/tests/utils.test.ts b/tests/utils.test.ts index fbd0fe89a..9db39943d 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,4 +1,5 @@ import { Collection, formatImageURL, hasProperty, iconBigintToHash, iconHashToBigInt, validateLength } from "../mod.ts"; +import { decode, encode } from "../util/base64.ts"; import { bigintToSnowflake, snowflakeToBigint } from "../util/bigint.ts"; import { removeTokenPrefix } from "../util/token.ts"; import { assertEquals, assertExists, assertNotEquals } from "./deps.ts"; @@ -407,3 +408,34 @@ Deno.test({ }); }, }); + +Deno.test({ + name: "[utils] encode some bytes to base64", + ignore: Deno.env.get("TEST_ENV") === "INTEGRATION", + async fn(t) { + assertEquals(encode(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])), "AQIDBAUGBwgJCg=="); + }, +}); + +Deno.test({ + name: "[utils] decode some base64 to bytes", + ignore: Deno.env.get("TEST_ENV") === "INTEGRATION", + async fn(t) { + assertEquals(decode("AQIDBAUGBwgJCg=="), new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])); + }, +}); + +Deno.test({ + name: "[utils] encode/decode base64 roundtrip should work", + ignore: Deno.env.get("TEST_ENV") === "INTEGRATION", + async fn(t) { + for (let i = 0; i < 10; i++) { + const bytes = []; + for (let i = 0; i < 10000; i++) { + bytes.push(Math.floor(Math.random() * 256)); + } + const data = new Uint8Array(bytes); + assertEquals(decode(encode(data)), data); + } + }, +}); diff --git a/util/base64.ts b/util/base64.ts new file mode 100644 index 000000000..30a81b42f --- /dev/null +++ b/util/base64.ts @@ -0,0 +1,274 @@ +/** + * CREDIT: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 + * Encodes a given Uint8Array, ArrayBuffer or string into RFC4648 base64 representation + * @param data + */ +export function encode(data: ArrayBuffer | string): string { + const uint8 = typeof data === "string" + ? new TextEncoder().encode(data) + : data instanceof Uint8Array + ? data + : new Uint8Array(data); + let result = "", + i; + const l = uint8.length; + for (i = 2; i < l; i += 3) { + result += base64abc[uint8[i - 2] >> 2]; + result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)]; + result += base64abc[((uint8[i - 1] & 0x0f) << 2) | (uint8[i] >> 6)]; + result += base64abc[uint8[i] & 0x3f]; + } + if (i === l + 1) { + // 1 octet yet to write + result += base64abc[uint8[i - 2] >> 2]; + result += base64abc[(uint8[i - 2] & 0x03) << 4]; + result += "=="; + } + if (i === l) { + // 2 octets yet to write + result += base64abc[uint8[i - 2] >> 2]; + result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)]; + result += base64abc[(uint8[i - 1] & 0x0f) << 2]; + result += "="; + } + return result; +} + +/** + * CREDIT: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 + * Decodes RFC4648 base64 string into an Uint8Array + * @param data + */ +export function decode(data: string): Uint8Array { + if (data.length % 4 !== 0) { + throw new Error("Unable to parse base64 string."); + } + const index = data.indexOf("="); + if (index !== -1 && index < data.length - 2) { + throw new Error("Unable to parse base64 string."); + } + let missingOctets = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0, + n = data.length, + result = new Uint8Array(3 * (n / 4)), + buffer; + for (let i = 0, j = 0; i < n; i += 4, j += 3) { + buffer = getBase64Code(data.charCodeAt(i)) << 18 | + getBase64Code(data.charCodeAt(i + 1)) << 12 | + getBase64Code(data.charCodeAt(i + 2)) << 6 | + getBase64Code(data.charCodeAt(i + 3)); + result[j] = buffer >> 16; + result[j + 1] = (buffer >> 8) & 0xFF; + result[j + 2] = buffer & 0xFF; + } + return result.subarray(0, result.length - missingOctets); +} + +/** + * CREDIT: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 + * @param charCode + */ +function getBase64Code(charCode: number): number { + if (charCode >= base64codes.length) { + throw new Error("Unable to parse base64 string."); + } + const code = base64codes[charCode]; + if (code === 255) { + throw new Error("Unable to parse base64 string."); + } + return code; +} + +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. +const base64abc = [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "+", + "/", +]; + +// CREDIT: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 +const base64codes = [ + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 255, + 62, + 255, + 255, + 255, + 63, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 255, + 255, + 255, + 0, + 255, + 255, + 255, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 255, + 255, + 255, + 255, + 255, + 255, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, +]; diff --git a/util/urlToBase64.ts b/util/urlToBase64.ts index 5ff07bf5c..c8485d480 100644 --- a/util/urlToBase64.ts +++ b/util/urlToBase64.ts @@ -1,3 +1,5 @@ +import { encode } from "./base64.ts"; + /** Converts a url to base 64. Useful for example, uploading/creating server emojis. */ export async function urlToBase64(url: string) { const buffer = await fetch(url).then((res) => res.arrayBuffer()); @@ -5,107 +7,3 @@ export async function urlToBase64(url: string) { const type = url.substring(url.lastIndexOf(".") + 1); return `data:image/${type};base64,${imageStr}`; } - -// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. -const base64abc = [ - "A", - "B", - "C", - "D", - "E", - "F", - "G", - "H", - "I", - "J", - "K", - "L", - "M", - "N", - "O", - "P", - "Q", - "R", - "S", - "T", - "U", - "V", - "W", - "X", - "Y", - "Z", - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", - "o", - "p", - "q", - "r", - "s", - "t", - "u", - "v", - "w", - "x", - "y", - "z", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "+", - "/", -]; - -/** - * CREDIT: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 - * Encodes a given Uint8Array, ArrayBuffer or string into RFC4648 base64 representation - * @param data - */ -export function encode(data: ArrayBuffer | string): string { - const uint8 = typeof data === "string" - ? new TextEncoder().encode(data) - : data instanceof Uint8Array - ? data - : new Uint8Array(data); - let result = "", - i; - const l = uint8.length; - for (i = 2; i < l; i += 3) { - result += base64abc[uint8[i - 2] >> 2]; - result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)]; - result += base64abc[((uint8[i - 1] & 0x0f) << 2) | (uint8[i] >> 6)]; - result += base64abc[uint8[i] & 0x3f]; - } - if (i === l + 1) { - // 1 octet yet to write - result += base64abc[uint8[i - 2] >> 2]; - result += base64abc[(uint8[i - 2] & 0x03) << 4]; - result += "=="; - } - if (i === l) { - // 2 octets yet to write - result += base64abc[uint8[i - 2] >> 2]; - result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)]; - result += base64abc[(uint8[i - 1] & 0x0f) << 2]; - result += "="; - } - return result; -}