From 177f573792f3f567e037bfc056005a26d1681f23 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 8 Mar 2024 13:14:45 -1000 Subject: [PATCH] Add new media handling package --- config/config.example.toml | 2 + packages/config-manager/config-type.type.ts | 2 + packages/config-manager/index.ts | 4 + packages/media-manager/backends/local.ts | 64 +++++ packages/media-manager/backends/s3.ts | 69 +++++ packages/media-manager/bun.lockb | Bin 0 -> 2753 bytes packages/media-manager/bunfig.toml | 2 + packages/media-manager/index.ts | 80 ++++++ packages/media-manager/media-converter.ts | 94 +++++++ packages/media-manager/package.json | 6 + .../tests/media-backends.test.ts | 243 ++++++++++++++++++ .../media-manager/tests/media-manager.test.ts | 65 +++++ packages/media-manager/tests/megamind.jpg | Bin 0 -> 5252 bytes 13 files changed, 631 insertions(+) create mode 100644 packages/media-manager/backends/local.ts create mode 100644 packages/media-manager/backends/s3.ts create mode 100755 packages/media-manager/bun.lockb create mode 100644 packages/media-manager/bunfig.toml create mode 100644 packages/media-manager/index.ts create mode 100644 packages/media-manager/media-converter.ts create mode 100644 packages/media-manager/package.json create mode 100644 packages/media-manager/tests/media-backends.test.ts create mode 100644 packages/media-manager/tests/media-manager.test.ts create mode 100644 packages/media-manager/tests/megamind.jpg diff --git a/config/config.example.toml b/config/config.example.toml index e9b95555..fe9bbe28 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -71,6 +71,8 @@ tls = true backend = "s3" # Whether to check the hash of media when uploading to avoid duplication deduplicate_media = true +# If media backend is "local", this is the folder where the files will be stored +local_uploads_folder = "uploads" [media.conversion] convert_images = false diff --git a/packages/config-manager/config-type.type.ts b/packages/config-manager/config-type.type.ts index 940716d5..6364dc39 100644 --- a/packages/config-manager/config-type.type.ts +++ b/packages/config-manager/config-type.type.ts @@ -101,6 +101,7 @@ export interface ConfigType { convert_images: boolean; convert_to: string; }; + local_uploads_folder: string; }; s3: { @@ -234,6 +235,7 @@ export const configDefaults: ConfigType = { convert_images: false, convert_to: "webp", }, + local_uploads_folder: "uploads", }, email: { send_on_report: false, diff --git a/packages/config-manager/index.ts b/packages/config-manager/index.ts index 03d1e4fc..d9cbb52f 100644 --- a/packages/config-manager/index.ts +++ b/packages/config-manager/index.ts @@ -7,6 +7,7 @@ import { parse, stringify, type JsonMap } from "@iarna/toml"; import type { ConfigType } from "./config-type.type"; +import { configDefaults } from "./config-type.type"; import merge from "merge-deep-ts"; export class ConfigManager { @@ -116,3 +117,6 @@ export class ConfigManager { return merge(configs) as T; } } + +export type { ConfigType }; +export const defaultConfig = configDefaults; diff --git a/packages/media-manager/backends/local.ts b/packages/media-manager/backends/local.ts new file mode 100644 index 00000000..5c955b84 --- /dev/null +++ b/packages/media-manager/backends/local.ts @@ -0,0 +1,64 @@ +import type { ConvertableMediaFormats } from "../media-converter"; +import { MediaConverter } from "../media-converter"; +import { MediaBackend, MediaBackendType, MediaHasher } from ".."; +import type { ConfigType } from "config-manager"; + +export class LocalMediaBackend extends MediaBackend { + constructor(private config: ConfigType) { + super(MediaBackendType.LOCAL); + } + + public async addFile(file: File) { + if (this.shouldConvertImages(this.config)) { + const fileExtension = file.name.split(".").pop(); + const mediaConverter = new MediaConverter( + fileExtension as ConvertableMediaFormats, + this.config.media.conversion + .convert_to as ConvertableMediaFormats + ); + file = await mediaConverter.convert(file); + } + + const hash = await new MediaHasher().getMediaHash(file); + + const newFile = Bun.file( + `${this.config.media.local_uploads_folder}/${hash}` + ); + + if (await newFile.exists()) { + throw new Error("File already exists"); + } + + await Bun.write(newFile, file); + + return { + uploadedFile: file, + path: `./uploads/${file.name}`, + hash: hash, + }; + } + + public async getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise + ): Promise { + const filename = await databaseHashFetcher(hash); + + if (!filename) return null; + + return this.getFile(filename); + } + + public async getFile(filename: string): Promise { + const file = Bun.file( + `${this.config.media.local_uploads_folder}/${filename}` + ); + + if (!(await file.exists())) return null; + + return new File([await file.arrayBuffer()], filename, { + type: file.type, + lastModified: file.lastModified, + }); + } +} diff --git a/packages/media-manager/backends/s3.ts b/packages/media-manager/backends/s3.ts new file mode 100644 index 00000000..8098e2f2 --- /dev/null +++ b/packages/media-manager/backends/s3.ts @@ -0,0 +1,69 @@ +import { S3Client } from "@bradenmacdonald/s3-lite-client"; +import type { ConvertableMediaFormats } from "../media-converter"; +import { MediaConverter } from "../media-converter"; +import { MediaBackend, MediaBackendType, MediaHasher } from ".."; +import type { ConfigType } from "config-manager"; + +export class S3MediaBackend extends MediaBackend { + constructor( + private config: ConfigType, + private s3Client = new S3Client({ + endPoint: config.s3.endpoint, + useSSL: true, + region: config.s3.region || "auto", + bucket: config.s3.bucket_name, + accessKey: config.s3.access_key, + secretKey: config.s3.secret_access_key, + }) + ) { + super(MediaBackendType.S3); + } + + public async addFile(file: File) { + if (this.shouldConvertImages(this.config)) { + const fileExtension = file.name.split(".").pop(); + const mediaConverter = new MediaConverter( + fileExtension as ConvertableMediaFormats, + this.config.media.conversion + .convert_to as ConvertableMediaFormats + ); + file = await mediaConverter.convert(file); + } + + const hash = await new MediaHasher().getMediaHash(file); + + await this.s3Client.putObject(file.name, file.stream(), { + size: file.size, + }); + + return { + uploadedFile: file, + hash: hash, + }; + } + + public async getFileByHash( + hash: string, + databaseHashFetcher: (sha256: string) => Promise + ): Promise { + const filename = await databaseHashFetcher(hash); + + if (!filename) return null; + + return this.getFile(filename); + } + + public async getFile(filename: string): Promise { + try { + await this.s3Client.statObject(filename); + } catch { + return null; + } + + const file = await this.s3Client.getObject(filename); + + return new File([await file.arrayBuffer()], filename, { + type: file.headers.get("Content-Type") || "undefined", + }); + } +} diff --git a/packages/media-manager/bun.lockb b/packages/media-manager/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..202c862de57b5b2b88f92c3c9007992821210802 GIT binary patch literal 2753 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6OCq1_p+%$C(-z zx&Itb=RC~C`LFgyNYzL7N0C1d#D)qo@5$O;X2=Cp1O#jlih%=-Zh-RhU<$x|UIvDS z5FkqhNbd#GJV2U*k%569NdExx1%Px7kPlL~zGi~qYALaOdRH1=RQz_bydCNFGraFc zJD2w-VR!w4fIyvd-T&Ukt;&A>lQle8>l;uTMF8Y$5U2oRSD^hMHaieAgBU~rnEsPM zwfa!~w}5PzI3Xio>coK_F`_`fCs4mJ1^PRI`VGj?5Aq)<4qzDM9}s2%Vh{k)AUReL z2MSrb6jB*5GC<5lHk!pkW%=&Z?WwPBOju&D>8)Q+e(zmN`!?69YtytO zzxnlSiH}+k8Tgh@uf$M0rQ9t|jnVL;R`6whcD2@x=TbL4O9HwPW-iEIFu+pDvhgkt zliwS^456Gb_a7x!8!j>`s;1JrI(50zVRk9?U z_v-}Bjq1@KcL+piMlFc`5y>5;A$w`XjqgDw=MAbJAejq`FOZvARzK_y6ks|TQ4(`z zrF((n7C|rRO@|M!JKGuBKEE;O%cdTlL+hjkvtD1gwo`ZCHNG3R2b=b6+V8z+s@_c= zx82JNkjzDncb2FX;=F$jy_=xr_~K@4)hdO#YoAzY*}a_o&F<5bT~AG-gk3lA-Q2uu z%J$0}Tzl&LIv(?em+ZXmU)=cb+^pR*6F_CfPyxv4YN)uGa(8ZkmitGbG?z_rQLy>Ffvdm&a6t!Pt#E_QAo_mOwY?t1J^JA{zCxBJ)rdS0csix zs9**HE}K$gBRilb?ApQQ4JiMC@(L(j8$k8B5TPGdCV}*U^n>iLfa?1N*B@Ll&s{QIwdHnwOiHoRSZ; zEG0g^*jP6wvm{kFIVUqUufzdOULQ@G2qQ8|N(zdt^!0&O>H(GOW#;SG>6_~lW4peA zp1Gc}UP*ctSaop;(0!Tt_zZ+90cr%so1PJ>*2LoC)S?oEQAw30sl^0M1FJ$dkf08T YJ;+)RcBbW)5Y!J;gRB{tItL{H0Oe8w{r~^~ literal 0 HcmV?d00001 diff --git a/packages/media-manager/bunfig.toml b/packages/media-manager/bunfig.toml new file mode 100644 index 00000000..bea1efe1 --- /dev/null +++ b/packages/media-manager/bunfig.toml @@ -0,0 +1,2 @@ +[install.scopes] +"@jsr" = "https://npm.jsr.io" diff --git a/packages/media-manager/index.ts b/packages/media-manager/index.ts new file mode 100644 index 00000000..77c2f581 --- /dev/null +++ b/packages/media-manager/index.ts @@ -0,0 +1,80 @@ +import type { ConfigType } from "config-manager"; + +export enum MediaBackendType { + LOCAL = "local", + S3 = "s3", +} + +interface UploadedFileMetadata { + uploadedFile: File; + path?: string; + hash: string; +} + +export class MediaHasher { + /** + * Returns the SHA-256 hash of a file in hex format + * @param media The file to hash + * @returns The SHA-256 hash of the file in hex format + */ + public async getMediaHash(media: File) { + const hash = new Bun.SHA256() + .update(await media.arrayBuffer()) + .digest("hex"); + + return hash; + } +} + +export class MediaBackend { + constructor(private backend: MediaBackendType) {} + + public getBackendType() { + return this.backend; + } + + public shouldConvertImages(config: ConfigType) { + return config.media.conversion.convert_images; + } + + /** + * Fetches file from backend from SHA-256 hash + * @param file SHA-256 hash of wanted file + * @param databaseHashFetcher Function that takes in a sha256 hash as input and outputs the filename of that file in the database + * @returns The file as a File object + */ + public getFileByHash( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + file: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + databaseHashFetcher: (sha256: string) => Promise + ): Promise { + return Promise.reject( + new Error("Do not call MediaBackend directly: use a subclass") + ); + } + + /** + * Fetches file from backend from filename + * @param filename File name + * @returns The file as a File object + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getFile(filename: string): Promise { + return Promise.reject( + new Error("Do not call MediaBackend directly: use a subclass") + ); + } + + /** + * Adds file to backend + * @param file File to add + * @returns Metadata about the uploaded file + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public addFile(file: File): Promise { + return Promise.reject( + new Error("Do not call MediaBackend directly: use a subclass") + ); + } +} diff --git a/packages/media-manager/media-converter.ts b/packages/media-manager/media-converter.ts new file mode 100644 index 00000000..2602f4cf --- /dev/null +++ b/packages/media-manager/media-converter.ts @@ -0,0 +1,94 @@ +/** + * @packageDocumentation + * @module MediaManager + * @description Handles media conversion between formats + */ +import sharp from "sharp"; + +export enum ConvertableMediaFormats { + PNG = "png", + WEBP = "webp", + JPEG = "jpeg", + JPG = "jpg", + AVIF = "avif", + JXL = "jxl", + HEIF = "heif", +} + +/** + * Handles media conversion between formats + */ +export class MediaConverter { + constructor( + public fromFormat: ConvertableMediaFormats, + public toFormat: ConvertableMediaFormats + ) {} + + /** + * Returns whether the media is convertable + * @returns Whether the media is convertable + */ + public isConvertable() { + return ( + this.fromFormat !== this.toFormat && + Object.values(ConvertableMediaFormats).includes(this.fromFormat) + ); + } + + /** + * Returns the file name with the extension replaced + * @param fileName File name to replace + * @returns File name with extension replaced + */ + private getReplacedFileName(fileName: string) { + return this.extractFilenameFromPath(fileName).replace( + new RegExp(`\\.${this.fromFormat}$`), + `.${this.toFormat}` + ); + } + + /** + * Extracts the filename from a path + * @param path Path to extract filename from + * @returns Extracted filename + */ + private extractFilenameFromPath(path: string) { + // Don't count escaped slashes as path separators + const pathParts = path.split(/(? = { + [P in keyof T]?: DeepPartial; +}; + +describe("MediaBackend", () => { + let mediaBackend: MediaBackend; + let mockConfig: ConfigType; + + beforeEach(() => { + mediaBackend = new MediaBackend(MediaBackendType.S3); + mockConfig = { + media: { + conversion: { + convert_images: true, + }, + }, + } as ConfigType; + }); + + it("should initialize with correct backend type", () => { + expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3); + }); + + it("should check if images should be converted", () => { + expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true); + mockConfig.media.conversion.convert_images = false; + expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false); + }); + + it("should throw error when calling getFileByHash", () => { + const mockHash = "test-hash"; + const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg"); + + expect( + mediaBackend.getFileByHash(mockHash, databaseHashFetcher) + ).rejects.toThrow(Error); + }); + + it("should throw error when calling getFile", () => { + const mockFilename = "test.jpg"; + + expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error); + }); + + it("should throw error when calling addFile", () => { + const mockFile = new File([""], "test.jpg"); + + expect(mediaBackend.addFile(mockFile)).rejects.toThrow(); + }); +}); + +describe("S3MediaBackend", () => { + let s3MediaBackend: S3MediaBackend; + let mockS3Client: Partial; + let mockConfig: DeepPartial; + let mockFile: File; + let mockMediaHasher: MediaHasher; + + beforeEach(() => { + mockConfig = { + s3: { + endpoint: "http://localhost:4566", + region: "us-east-1", + bucket_name: "test-bucket", + access_key: "test-access-key", + secret_access_key: "test-secret-access-key", + public_url: "test", + }, + media: { + conversion: { + convert_to: ConvertableMediaFormats.PNG, + }, + }, + }; + mockFile = new File([new TextEncoder().encode("test")], "test.jpg"); + mockMediaHasher = new MediaHasher(); + mockS3Client = { + putObject: jest.fn().mockResolvedValue({}), + statObject: jest.fn().mockResolvedValue({}), + getObject: jest.fn().mockResolvedValue({ + blob: jest.fn().mockResolvedValue(new Blob()), + headers: new Headers({ "Content-Type": "image/jpeg" }), + }), + } as Partial; + s3MediaBackend = new S3MediaBackend( + mockConfig as ConfigType, + mockS3Client as S3Client + ); + }); + + it("should initialize with correct type", () => { + expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3); + }); + + it("should add file", async () => { + const mockHash = "test-hash"; + spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); + + const result = await s3MediaBackend.addFile(mockFile); + + expect(result.uploadedFile).toEqual(mockFile); + expect(result.hash).toHaveLength(64); + expect(mockS3Client.putObject).toHaveBeenCalledWith( + mockFile.name, + expect.any(ReadableStream), + { size: mockFile.size } + ); + }); + + it("should get file by hash", async () => { + const mockHash = "test-hash"; + const mockFilename = "test.jpg"; + const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); + mockS3Client.statObject = jest.fn().mockResolvedValue({}); + mockS3Client.getObject = jest.fn().mockResolvedValue({ + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), + headers: new Headers({ "Content-Type": "image/jpeg" }), + }); + + const file = await s3MediaBackend.getFileByHash( + mockHash, + databaseHashFetcher + ); + + expect(file).not.toBeNull(); + expect(file?.name).toEqual(mockFilename); + expect(file?.type).toEqual("image/jpeg"); + }); + + it("should get file", async () => { + const mockFilename = "test.jpg"; + mockS3Client.statObject = jest.fn().mockResolvedValue({}); + mockS3Client.getObject = jest.fn().mockResolvedValue({ + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), + headers: new Headers({ "Content-Type": "image/jpeg" }), + }); + + const file = await s3MediaBackend.getFile(mockFilename); + + expect(file).not.toBeNull(); + expect(file?.name).toEqual(mockFilename); + expect(file?.type).toEqual("image/jpeg"); + }); +}); + +describe("LocalMediaBackend", () => { + let localMediaBackend: LocalMediaBackend; + let mockConfig: ConfigType; + let mockFile: File; + let mockMediaHasher: MediaHasher; + + beforeEach(() => { + mockConfig = { + media: { + conversion: { + convert_images: true, + convert_to: ConvertableMediaFormats.PNG, + }, + local_uploads_folder: "./uploads", + }, + } as ConfigType; + mockFile = Bun.file(__dirname + "/megamind.jpg") as unknown as File; + mockMediaHasher = new MediaHasher(); + localMediaBackend = new LocalMediaBackend(mockConfig); + }); + + it("should initialize with correct type", () => { + expect(localMediaBackend.getBackendType()).toEqual( + MediaBackendType.LOCAL + ); + }); + + it("should add file", async () => { + const mockHash = "test-hash"; + spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); + const mockMediaConverter = new MediaConverter( + ConvertableMediaFormats.JPG, + ConvertableMediaFormats.PNG + ); + spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile); + // @ts-expect-error This is a mock + spyOn(Bun, "file").mockImplementationOnce(() => ({ + exists: () => Promise.resolve(false), + })); + spyOn(Bun, "write").mockImplementationOnce(() => + Promise.resolve(mockFile.size) + ); + + const result = await localMediaBackend.addFile(mockFile); + + expect(result.uploadedFile).toEqual(mockFile); + expect(result.path).toEqual(`./uploads/megamind.png`); + expect(result.hash).toHaveLength(64); + }); + + it("should get file by hash", async () => { + const mockHash = "test-hash"; + const mockFilename = "test.jpg"; + const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); + // @ts-expect-error This is a mock + spyOn(Bun, "file").mockImplementationOnce(() => ({ + exists: () => Promise.resolve(true), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + type: "image/jpeg", + lastModified: 123456789, + })); + + const file = await localMediaBackend.getFileByHash( + mockHash, + databaseHashFetcher + ); + + expect(file).not.toBeNull(); + expect(file?.name).toEqual(mockFilename); + expect(file?.type).toEqual("image/jpeg"); + }); + + it("should get file", async () => { + const mockFilename = "test.jpg"; + // @ts-expect-error This is a mock + spyOn(Bun, "file").mockImplementationOnce(() => ({ + exists: () => Promise.resolve(true), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + type: "image/jpeg", + lastModified: 123456789, + })); + + const file = await localMediaBackend.getFile(mockFilename); + + expect(file).not.toBeNull(); + expect(file?.name).toEqual(mockFilename); + expect(file?.type).toEqual("image/jpeg"); + }); +}); diff --git a/packages/media-manager/tests/media-manager.test.ts b/packages/media-manager/tests/media-manager.test.ts new file mode 100644 index 00000000..017f3b6a --- /dev/null +++ b/packages/media-manager/tests/media-manager.test.ts @@ -0,0 +1,65 @@ +// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts +import { describe, it, expect, beforeEach } from "bun:test"; +import { MediaConverter, ConvertableMediaFormats } from "../media-converter"; + +describe("MediaConverter", () => { + let mediaConverter: MediaConverter; + + beforeEach(() => { + mediaConverter = new MediaConverter( + ConvertableMediaFormats.JPG, + ConvertableMediaFormats.PNG + ); + }); + + it("should initialize with correct formats", () => { + expect(mediaConverter.fromFormat).toEqual(ConvertableMediaFormats.JPG); + expect(mediaConverter.toFormat).toEqual(ConvertableMediaFormats.PNG); + }); + + it("should check if media is convertable", () => { + expect(mediaConverter.isConvertable()).toBe(true); + mediaConverter.toFormat = ConvertableMediaFormats.JPG; + expect(mediaConverter.isConvertable()).toBe(false); + }); + + it("should replace file name extension", () => { + const fileName = "test.jpg"; + const expectedFileName = "test.png"; + // Written like this because it's a private function + expect(mediaConverter["getReplacedFileName"](fileName)).toEqual( + expectedFileName + ); + }); + + describe("Filename extractor", () => { + it("should extract filename from path", () => { + const path = "path/to/test.jpg"; + const expectedFileName = "test.jpg"; + expect(mediaConverter["extractFilenameFromPath"](path)).toEqual( + expectedFileName + ); + }); + + it("should handle escaped slashes", () => { + const path = "path/to/test\\/test.jpg"; + const expectedFileName = "test\\/test.jpg"; + expect(mediaConverter["extractFilenameFromPath"](path)).toEqual( + expectedFileName + ); + }); + }); + + it("should convert media", async () => { + const file = Bun.file(__dirname + "/megamind.jpg"); + + const convertedFile = await mediaConverter.convert( + file as unknown as File + ); + + expect(convertedFile.name).toEqual("megamind.png"); + expect(convertedFile.type).toEqual( + `image/${ConvertableMediaFormats.PNG}` + ); + }); +}); diff --git a/packages/media-manager/tests/megamind.jpg b/packages/media-manager/tests/megamind.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0f8f035aac906b24ba9385613cd96466caa463a4 GIT binary patch literal 5252 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