diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index ef18e041..26a5685b 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -192,7 +192,7 @@ export interface Config { /** @default false */ convert_images: boolean; - /** @default "webp" */ + /** @default "image/webp" */ convert_to: string; }; }; @@ -494,7 +494,7 @@ export const defaultConfig: Config = { local_uploads_folder: "uploads", conversion: { convert_images: false, - convert_to: "webp", + convert_to: "image/webp", }, }, s3: { diff --git a/packages/media-manager/index.ts b/packages/media-manager/index.ts index 6782afdc..b24c81d1 100644 --- a/packages/media-manager/index.ts +++ b/packages/media-manager/index.ts @@ -1,6 +1,5 @@ import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; import type { Config } from "config-manager"; -import type { ConvertableMediaFormats } from "./media-converter"; import { MediaConverter } from "./media-converter"; export enum MediaBackendType { @@ -103,13 +102,11 @@ export class LocalMediaBackend extends MediaBackend { public async addFile(file: File) { let convertedFile = 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, + const mediaConverter = new MediaConverter(); + convertedFile = await mediaConverter.convert( + file, + this.config.media.conversion.convert_to, ); - convertedFile = await mediaConverter.convert(file); } const hash = await new MediaHasher().getMediaHash(convertedFile); @@ -174,13 +171,11 @@ export class S3MediaBackend extends MediaBackend { public async addFile(file: File) { let convertedFile = 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, + const mediaConverter = new MediaConverter(); + convertedFile = await mediaConverter.convert( + file, + this.config.media.conversion.convert_to, ); - convertedFile = await mediaConverter.convert(file); } const hash = await new MediaHasher().getMediaHash(convertedFile); diff --git a/packages/media-manager/media-converter.ts b/packages/media-manager/media-converter.ts index 9ff411c4..4bdd2fbc 100644 --- a/packages/media-manager/media-converter.ts +++ b/packages/media-manager/media-converter.ts @@ -5,34 +5,35 @@ */ import sharp from "sharp"; -export enum ConvertableMediaFormats { - PNG = "png", - WEBP = "webp", - JPEG = "jpeg", - JPG = "jpg", - AVIF = "avif", - JXL = "jxl", - HEIF = "heif", -} +export const supportedMediaFormats = [ + "image/png", + "image/jpeg", + "image/webp", + "image/avif", + "image/svg+xml", + "image/gif", + "image/tiff", +]; + +export const supportedOutputFormats = [ + "image/jpeg", + "image/png", + "image/webp", + "image/avif", + "image/gif", + "image/tiff", +]; /** * 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) - ); + public isConvertable(file: File) { + return supportedMediaFormats.includes(file.type); } /** @@ -40,10 +41,10 @@ export class MediaConverter { * @param fileName File name to replace * @returns File name with extension replaced */ - private getReplacedFileName(fileName: string) { + private getReplacedFileName(fileName: string, newExtension: string) { return this.extractFilenameFromPath(fileName).replace( - new RegExp(`\\.${this.fromFormat}$`), - `.${this.toFormat}`, + /\.[^/.]+$/, + `.${newExtension}`, ); } @@ -63,32 +64,44 @@ export class MediaConverter { * @param media Media to convert * @returns Converted media */ - public async convert(media: File) { - if (!this.isConvertable()) { + public async convert( + media: File, + toMime: (typeof supportedMediaFormats)[number], + ) { + if (!this.isConvertable(media)) { return media; } + if (!supportedOutputFormats.includes(toMime)) { + throw new Error( + `Unsupported image output format: ${toMime}. Supported formats: ${supportedOutputFormats.join( + ", ", + )}`, + ); + } + const sharpCommand = sharp(await media.arrayBuffer()); - // Calculate newFilename before changing formats to prevent errors with jpg files - const newFilename = this.getReplacedFileName(media.name); + const commandName = toMime.split("/")[1] as + | "jpeg" + | "png" + | "webp" + | "avif" + | "gif" + | "tiff"; - if (this.fromFormat === ConvertableMediaFormats.JPG) { - this.fromFormat = ConvertableMediaFormats.JPEG; - } - - if (this.toFormat === ConvertableMediaFormats.JPG) { - this.toFormat = ConvertableMediaFormats.JPEG; - } - - const convertedBuffer = await sharpCommand[this.toFormat]().toBuffer(); + const convertedBuffer = await sharpCommand[commandName]().toBuffer(); // Convert the buffer to a BlobPart const buffer = new Blob([convertedBuffer]); - return new File([buffer], newFilename, { - type: `image/${this.toFormat}`, - lastModified: Date.now(), - }); + return new File( + [buffer], + this.getReplacedFileName(media.name || "image", commandName), + { + type: toMime, + lastModified: Date.now(), + }, + ); } } diff --git a/packages/media-manager/tests/media-backends.test.ts b/packages/media-manager/tests/media-backends.test.ts index 102b6e9b..25d60271 100644 --- a/packages/media-manager/tests/media-backends.test.ts +++ b/packages/media-manager/tests/media-backends.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test"; import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; import type { Config } from "config-manager"; -// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/backends/s3.test.ts import { LocalMediaBackend, MediaBackend, @@ -9,7 +8,7 @@ import { MediaHasher, S3MediaBackend, } from ".."; -import { ConvertableMediaFormats, MediaConverter } from "../media-converter"; +import { MediaConverter } from "../media-converter"; type DeepPartial = { [P in keyof T]?: DeepPartial; @@ -115,7 +114,7 @@ describe("S3MediaBackend", () => { }, media: { conversion: { - convert_to: ConvertableMediaFormats.PNG, + convert_to: "image/png", }, }, }; @@ -201,7 +200,7 @@ describe("LocalMediaBackend", () => { media: { conversion: { convert_images: true, - convert_to: ConvertableMediaFormats.PNG, + convert_to: "image/png", }, local_uploads_folder: "./uploads", }, @@ -220,10 +219,7 @@ describe("LocalMediaBackend", () => { it("should add file", async () => { const mockHash = "test-hash"; spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); - const mockMediaConverter = new MediaConverter( - ConvertableMediaFormats.JPG, - ConvertableMediaFormats.PNG, - ); + const mockMediaConverter = new MediaConverter(); spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile); // @ts-expect-error This is a mock spyOn(Bun, "file").mockImplementationOnce(() => ({ diff --git a/packages/media-manager/tests/media-manager.test.ts b/packages/media-manager/tests/media-manager.test.ts index b6125b9d..bf19e92d 100644 --- a/packages/media-manager/tests/media-manager.test.ts +++ b/packages/media-manager/tests/media-manager.test.ts @@ -1,33 +1,20 @@ // FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts import { beforeEach, describe, expect, it } from "bun:test"; -import { ConvertableMediaFormats, MediaConverter } from "../media-converter"; +import { MediaConverter } 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); + mediaConverter = new MediaConverter(); }); 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( + // @ts-ignore + expect(mediaConverter.getReplacedFileName(fileName, "png")).toEqual( expectedFileName, ); }); @@ -36,6 +23,7 @@ describe("MediaConverter", () => { it("should extract filename from path", () => { const path = "path/to/test.jpg"; const expectedFileName = "test.jpg"; + // @ts-ignore expect(mediaConverter.extractFilenameFromPath(path)).toEqual( expectedFileName, ); @@ -44,6 +32,7 @@ describe("MediaConverter", () => { it("should handle escaped slashes", () => { const path = "path/to/test\\/test.jpg"; const expectedFileName = "test\\/test.jpg"; + // @ts-ignore expect(mediaConverter.extractFilenameFromPath(path)).toEqual( expectedFileName, ); @@ -55,11 +44,10 @@ describe("MediaConverter", () => { const convertedFile = await mediaConverter.convert( file as unknown as File, + "image/png", ); expect(convertedFile.name).toEqual("megamind.png"); - expect(convertedFile.type).toEqual( - `image/${ConvertableMediaFormats.PNG}`, - ); + expect(convertedFile.type).toEqual("image/png"); }); }); diff --git a/server/api/api/v1/emojis/index.ts b/server/api/api/v1/emojis/index.ts index 009b85cd..dd293edb 100644 --- a/server/api/api/v1/emojis/index.ts +++ b/server/api/api/v1/emojis/index.ts @@ -85,15 +85,17 @@ export default (app: Hono) => : await mimeLookup(element); if (!contentType.startsWith("image/")) { - return jsonResponse( - { - error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, - }, + return errorResponse( + `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`, 422, ); } if (element instanceof File) { + if (!element.name) { + return errorResponse("File must have a name", 422); + } + const media = await MediaBackend.fromBackendType( config.media.backend, config,