AssetsManager.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. import { createRequire } from "node:module";
  2. import { extname, join, resolve } from "node:path";
  3. import { fileURLToPath } from "node:url";
  4. import { tmpdir } from "node:os";
  5. import { createReadStream, createWriteStream } from "node:fs";
  6. import { readFile, writeFile } from "node:fs/promises";
  7. import { pipeline } from "node:stream/promises";
  8. import { promisify } from "node:util";
  9. import got from "got";
  10. import glob from "glob";
  11. import FormData from "form-data";
  12. import sharp from "sharp";
  13. import { lookup as mimeLookup } from "mime-types";
  14. import { Metadata } from "./PresenceCompiler";
  15. import { getFolderLetter } from "../util.js";
  16. const require = createRequire(import.meta.url),
  17. rootPath = resolve(fileURLToPath(new URL(".", import.meta.url)), "../.."),
  18. cdnBase = "https://cdn.rcd.gg",
  19. globAsync = promisify(glob);
  20. export default class AssetsManager {
  21. cwd: string;
  22. constructor(
  23. public service: string,
  24. public options?: {
  25. cwd?: string;
  26. }
  27. ) {
  28. this.cwd = options?.cwd ?? rootPath;
  29. }
  30. get assetBaseUrl() {
  31. return `${cdnBase}/PreMiD/${encodeURI(
  32. this.presenceFolder.replace(`${this.cwd}/`, "")
  33. ).replace("#", "%23")}/assets`;
  34. }
  35. get presenceFolder() {
  36. //TODO Detect if the presence is a website or a program without using glob since the file may not exist anymore at this point
  37. const type: "websites" | "programs" = "websites";
  38. return `${this.cwd}/${type}/${getFolderLetter(this.service)}/${
  39. this.service
  40. }`;
  41. }
  42. getFileExtension(url: string) {
  43. return extname(new URL(url).pathname);
  44. }
  45. async allTsFiles() {
  46. return (
  47. await globAsync(`{websites,programs}/**/${this.service}/**/*.ts`, {
  48. absolute: true,
  49. })
  50. ).filter(file => !file.endsWith(".d.ts"));
  51. }
  52. get metadata(): Metadata {
  53. return require(resolve(this.presenceFolder, "metadata.json"));
  54. }
  55. /**
  56. * Gets all assets used in the presence
  57. *
  58. * Includes the logo, thumbnail, and all other assets used in the presence
  59. *
  60. * @note Includes both assets uploaded to the cdn, and assets that are not uploaded to the cdn
  61. *
  62. * @returns A list of all assets used in the presence
  63. */
  64. async allAssets(): Promise<{
  65. logo: string;
  66. thumbnail: string;
  67. assets: Set<string>;
  68. }> {
  69. const assets = new Set<string>(),
  70. { logo, thumbnail } = this.metadata,
  71. files = await this.allTsFiles();
  72. await Promise.all(
  73. files.map(async tsfile => {
  74. const file = await readFile(tsfile, "utf8");
  75. //* A regex to match all image urls in the file
  76. const regex =
  77. /(?<=["'`])(https?:\/\/.*?\.(?:png|jpg|jpeg|gif|webp)(?:[?][^'"`]+)?)(?=["'`])/g;
  78. let match: RegExpExecArray | null;
  79. while ((match = regex.exec(file)) !== null) {
  80. //* If the url contains a template literal, skip it
  81. if (match[1].includes(`\${`)) continue;
  82. //* Regex to check if the url contains + " or + ' or + `
  83. const regex2 = /(?<=\+ )["'`].*?["'`]/g;
  84. if (regex2.test(match[1])) continue;
  85. if (match[1] === logo || match[1] === thumbnail) continue;
  86. assets.add(match[1]);
  87. }
  88. })
  89. );
  90. return {
  91. logo,
  92. thumbnail,
  93. assets,
  94. };
  95. }
  96. /**
  97. * The assets should be uploaded to the following urls:
  98. * - logo: getAssetBaseUrl()/logo.<extension>
  99. * - thumbnail: getAssetBaseUrl()/thumbnail.<extension>
  100. * - all other assets: getAssetBaseUrl()/<index>.<extension>
  101. *
  102. * The indexes should be kept in order, so if there are 3 assets, they should be uploaded to:
  103. * - getAssetBaseUrl()/0.<extension>
  104. * - getAssetBaseUrl()/1.<extension>
  105. * - getAssetBaseUrl()/2.<extension>
  106. *
  107. * Any no longer used assets will be added to the toBeDeleted set
  108. * Any assets that have been uploaded already but will now be moved to a different index will be added to the toBeMoved map
  109. * Any assets that have not been uploaded yet will be added to the toBeUploaded map
  110. */
  111. async getAssetsChanges(): Promise<{
  112. toBeUploaded: Map<string, string>;
  113. toBeMoved: Map<string, string>;
  114. toBeDeleted: Set<string>;
  115. }> {
  116. const [assets, cdnAssets] = await Promise.all([
  117. this.allAssets(),
  118. this.getCdnAssets(),
  119. ]),
  120. result = {
  121. toBeUploaded: new Map<string, string>(),
  122. toBeMoved: new Map<string, string>(),
  123. toBeDeleted: new Set<string>(),
  124. };
  125. if (!cdnAssets.logo) {
  126. const newLogo = `${this.assetBaseUrl}/logo${this.getFileExtension(
  127. assets.logo
  128. )}`;
  129. result.toBeUploaded.set(assets.logo, newLogo);
  130. } else if (assets.logo !== cdnAssets.logo) {
  131. const newLogo = `${this.assetBaseUrl}/logo${this.getFileExtension(
  132. assets.logo
  133. )}`;
  134. //* If the logo has a different extension, delete the old logo
  135. if (!this.canBePut(cdnAssets.logo, newLogo))
  136. result.toBeDeleted.add(cdnAssets.logo);
  137. result.toBeUploaded.set(assets.logo, newLogo);
  138. }
  139. if (!cdnAssets.thumbnail) {
  140. const newThumbnail = `${
  141. this.assetBaseUrl
  142. }/thumbnail${this.getFileExtension(assets.thumbnail)}`;
  143. result.toBeUploaded.set(assets.thumbnail, newThumbnail);
  144. } else if (assets.thumbnail !== cdnAssets.thumbnail) {
  145. const newThumbnail = `${
  146. this.assetBaseUrl
  147. }/thumbnail${this.getFileExtension(assets.thumbnail)}`;
  148. //* If the thumbnail has a different extension, delete the old thumbnail
  149. if (!this.canBePut(cdnAssets.thumbnail, newThumbnail))
  150. result.toBeDeleted.add(cdnAssets.thumbnail);
  151. result.toBeUploaded.set(assets.thumbnail, newThumbnail);
  152. }
  153. const cdnAssetsInUse = new Map<number, string>(),
  154. usedIndexes = new Set<number>();
  155. if (cdnAssets.assets) {
  156. for (const [index, asset] of cdnAssets.assets) {
  157. if (assets.assets.has(asset)) {
  158. cdnAssetsInUse.set(index, asset);
  159. usedIndexes.add(index);
  160. } else result.toBeDeleted.add(asset);
  161. }
  162. }
  163. const newAssets = new Set<string>();
  164. for (const asset of assets.assets)
  165. if (!asset.startsWith(cdnBase)) newAssets.add(asset);
  166. let index = 0;
  167. for (const asset of newAssets) {
  168. while (usedIndexes.has(index)) index++;
  169. const newAsset = `${this.assetBaseUrl}/${index}${this.getFileExtension(
  170. asset
  171. )}`;
  172. result.toBeUploaded.set(asset, newAsset);
  173. usedIndexes.add(index);
  174. index++;
  175. }
  176. const missingIndexes = this.findMissing([...usedIndexes]);
  177. if (missingIndexes.length) {
  178. const cdnAssetsInUseArray = [...cdnAssetsInUse].sort(([a], [b]) => b - a);
  179. for (const index of missingIndexes) {
  180. const last = cdnAssetsInUseArray.pop();
  181. if (!last) break;
  182. const [_, asset] = last,
  183. newAsset = `${this.assetBaseUrl}/${index}${this.getFileExtension(
  184. asset
  185. )}`;
  186. result.toBeMoved.set(asset, newAsset);
  187. }
  188. }
  189. return result;
  190. }
  191. async getCdnAssets(): Promise<{
  192. logo: string | false;
  193. thumbnail: string | false;
  194. assets: Map<number, string> | false;
  195. }> {
  196. const assets = new Map<number, string>();
  197. let assetFound = true,
  198. index = 0;
  199. while (assetFound) {
  200. const asset = await this.doesAssetExistAnyExtension(
  201. `${this.assetBaseUrl}/${index}`
  202. );
  203. if (asset) {
  204. assets.set(index, asset);
  205. index++;
  206. } else {
  207. assetFound = false;
  208. }
  209. }
  210. const [logo, thumbnail] = await Promise.all([
  211. this.doesAssetExistAnyExtension(`${this.assetBaseUrl}/logo`),
  212. this.doesAssetExistAnyExtension(`${this.assetBaseUrl}/thumbnail`),
  213. ]);
  214. return {
  215. logo,
  216. thumbnail,
  217. assets: assets.size ? assets : false,
  218. };
  219. }
  220. canBePut(oldUrl: string, newUrl: string): boolean {
  221. return this.getFileExtension(oldUrl) === this.getFileExtension(newUrl);
  222. }
  223. async doesAssetExist(url: string): Promise<boolean> {
  224. return got
  225. .head(url)
  226. .then(() => true)
  227. .catch(() => false);
  228. }
  229. async doesAssetExistAnyExtension(url: string): Promise<string | false> {
  230. const extensions = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
  231. for (const extension of extensions) {
  232. const newUrl = `${url}${extension}`;
  233. if (await this.doesAssetExist(newUrl)) return newUrl;
  234. }
  235. return false;
  236. }
  237. async uploadAssets(assets: Map<string, string>) {
  238. let errors: string[] = [];
  239. await Promise.all(
  240. [...assets.entries()].map(async ([url, newUrl]) => {
  241. const extension = this.getFileExtension(url),
  242. mimeType = mimeLookup(extension);
  243. if (!mimeType || !mimeType.startsWith("image/")) {
  244. errors.push(
  245. `Tried to upload an asset with an invalid extension: ${url}`
  246. );
  247. return;
  248. }
  249. const random = Math.random().toString(36).substring(2, 15),
  250. filename = `premid-assetmanager-${random}${extension}`,
  251. fileLocation = join(tmpdir(), filename);
  252. let finalFileLocation = fileLocation;
  253. try {
  254. const stream = got.stream(url);
  255. await pipeline(stream, createWriteStream(fileLocation));
  256. if (stream.response?.url.split("/")[3].split(".")[0] === "removed") {
  257. errors.push(`Asset ${url} was removed from the server`);
  258. return;
  259. }
  260. } catch (error) {
  261. errors.push(`Error while downloading asset ${url}: ${error.message}`);
  262. return;
  263. }
  264. if (!newUrl.includes("thumbnail")) {
  265. const file = sharp(fileLocation),
  266. metadata = await file.metadata();
  267. if (metadata.width !== 512 || metadata.height !== 512) {
  268. try {
  269. const newFileLocation = join(tmpdir(), `resized-${filename}`);
  270. await file
  271. .resize(512, 512, {
  272. fit: "contain",
  273. background: { r: 0, g: 0, b: 0, alpha: 0 },
  274. })
  275. .toFile(newFileLocation);
  276. finalFileLocation = newFileLocation;
  277. } catch (error) {
  278. errors.push(
  279. `Error while resizing asset ${url}: ${error.message}`
  280. );
  281. return;
  282. }
  283. }
  284. }
  285. const form = new FormData();
  286. form.append("file", createReadStream(finalFileLocation), {
  287. filename,
  288. contentType: mimeType,
  289. });
  290. try {
  291. //* If the asset already exists, make a put request instead of a post request
  292. if (await this.doesAssetExist(newUrl)) {
  293. await got.put(newUrl, {
  294. body: form,
  295. headers: {
  296. ...form.getHeaders(),
  297. Authorization: process.env.CDN_TOKEN,
  298. },
  299. retry: {
  300. limit: 0,
  301. },
  302. });
  303. } else {
  304. await got.post(newUrl, {
  305. body: form,
  306. headers: {
  307. ...form.getHeaders(),
  308. Authorization: process.env.CDN_TOKEN,
  309. },
  310. retry: {
  311. limit: 0,
  312. },
  313. });
  314. }
  315. } catch (error) {
  316. errors.push(
  317. `Failed to upload asset ${url} to ${newUrl} (${
  318. "request" in error ? error.request.method : ""
  319. }): ${"message" in error ? error.message : error.toString()}`
  320. );
  321. }
  322. })
  323. );
  324. return errors;
  325. }
  326. async deleteAssets(assets: string[] | Set<string>) {
  327. let errors: string[] = [];
  328. await Promise.all(
  329. [...assets].map(async asset => {
  330. try {
  331. if (!(await this.doesAssetExist(asset))) return;
  332. await got.delete(asset, {
  333. headers: {
  334. Authorization: process.env.CDN_TOKEN,
  335. },
  336. });
  337. } catch (error) {
  338. errors.push(
  339. `Failed to delete asset ${asset}: ${
  340. "message" in error ? error.message : error.toString()
  341. }`
  342. );
  343. }
  344. })
  345. );
  346. return errors;
  347. }
  348. findMissing(numbers: number[]) {
  349. numbers.push(-1); //? Make sure there is at least one number in the array
  350. const max = Math.max(...numbers),
  351. min = Math.min(...numbers),
  352. missing = [];
  353. for (let i = min; i <= max; i++) if (!numbers.includes(i)) missing.push(i);
  354. return missing;
  355. }
  356. async replaceInFiles(replacements: Map<string, string>) {
  357. const allFiles = await this.allTsFiles();
  358. await Promise.all(
  359. allFiles.map(async tsfile => {
  360. let file = await readFile(tsfile, "utf8"),
  361. changed = false;
  362. for (const [oldUrl, newUrl] of replacements) {
  363. if (!file.includes(oldUrl)) continue;
  364. file = file.replaceAll(oldUrl, newUrl);
  365. changed = true;
  366. }
  367. if (changed) {
  368. await writeFile(tsfile, file, {
  369. encoding: "utf8",
  370. });
  371. }
  372. })
  373. );
  374. let metadata = await readFile(
  375. resolve(this.presenceFolder, "metadata.json"),
  376. "utf8"
  377. ),
  378. changed = false;
  379. for (const [oldUrl, newUrl] of replacements) {
  380. if (!metadata.includes(oldUrl)) continue;
  381. metadata = metadata.replaceAll(oldUrl, newUrl);
  382. changed = true;
  383. }
  384. if (changed) {
  385. await writeFile(resolve(this.presenceFolder, "metadata.json"), metadata, {
  386. encoding: "utf8",
  387. });
  388. }
  389. }
  390. }