refactor(api): 🔥 Refactor media uploader code

This commit is contained in:
Jesse Wierzbinski 2024-05-12 14:30:27 -10:00
parent 9566387273
commit 303928f960
No known key found for this signature in database
6 changed files with 80 additions and 86 deletions

View file

@ -192,7 +192,7 @@ export interface Config {
/** @default false */ /** @default false */
convert_images: boolean; convert_images: boolean;
/** @default "webp" */ /** @default "image/webp" */
convert_to: string; convert_to: string;
}; };
}; };
@ -494,7 +494,7 @@ export const defaultConfig: Config = {
local_uploads_folder: "uploads", local_uploads_folder: "uploads",
conversion: { conversion: {
convert_images: false, convert_images: false,
convert_to: "webp", convert_to: "image/webp",
}, },
}, },
s3: { s3: {

View file

@ -1,6 +1,5 @@
import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager"; import type { Config } from "config-manager";
import type { ConvertableMediaFormats } from "./media-converter";
import { MediaConverter } from "./media-converter"; import { MediaConverter } from "./media-converter";
export enum MediaBackendType { export enum MediaBackendType {
@ -103,13 +102,11 @@ export class LocalMediaBackend extends MediaBackend {
public async addFile(file: File) { public async addFile(file: File) {
let convertedFile = file; let convertedFile = file;
if (this.shouldConvertImages(this.config)) { if (this.shouldConvertImages(this.config)) {
const fileExtension = file.name.split(".").pop(); const mediaConverter = new MediaConverter();
const mediaConverter = new MediaConverter( convertedFile = await mediaConverter.convert(
fileExtension as ConvertableMediaFormats, file,
this.config.media.conversion this.config.media.conversion.convert_to,
.convert_to as ConvertableMediaFormats,
); );
convertedFile = await mediaConverter.convert(file);
} }
const hash = await new MediaHasher().getMediaHash(convertedFile); const hash = await new MediaHasher().getMediaHash(convertedFile);
@ -174,13 +171,11 @@ export class S3MediaBackend extends MediaBackend {
public async addFile(file: File) { public async addFile(file: File) {
let convertedFile = file; let convertedFile = file;
if (this.shouldConvertImages(this.config)) { if (this.shouldConvertImages(this.config)) {
const fileExtension = file.name.split(".").pop(); const mediaConverter = new MediaConverter();
const mediaConverter = new MediaConverter( convertedFile = await mediaConverter.convert(
fileExtension as ConvertableMediaFormats, file,
this.config.media.conversion this.config.media.conversion.convert_to,
.convert_to as ConvertableMediaFormats,
); );
convertedFile = await mediaConverter.convert(file);
} }
const hash = await new MediaHasher().getMediaHash(convertedFile); const hash = await new MediaHasher().getMediaHash(convertedFile);

View file

@ -5,34 +5,35 @@
*/ */
import sharp from "sharp"; import sharp from "sharp";
export enum ConvertableMediaFormats { export const supportedMediaFormats = [
PNG = "png", "image/png",
WEBP = "webp", "image/jpeg",
JPEG = "jpeg", "image/webp",
JPG = "jpg", "image/avif",
AVIF = "avif", "image/svg+xml",
JXL = "jxl", "image/gif",
HEIF = "heif", "image/tiff",
} ];
export const supportedOutputFormats = [
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
"image/gif",
"image/tiff",
];
/** /**
* Handles media conversion between formats * Handles media conversion between formats
*/ */
export class MediaConverter { export class MediaConverter {
constructor(
public fromFormat: ConvertableMediaFormats,
public toFormat: ConvertableMediaFormats,
) {}
/** /**
* Returns whether the media is convertable * Returns whether the media is convertable
* @returns Whether the media is convertable * @returns Whether the media is convertable
*/ */
public isConvertable() { public isConvertable(file: File) {
return ( return supportedMediaFormats.includes(file.type);
this.fromFormat !== this.toFormat &&
Object.values(ConvertableMediaFormats).includes(this.fromFormat)
);
} }
/** /**
@ -40,10 +41,10 @@ export class MediaConverter {
* @param fileName File name to replace * @param fileName File name to replace
* @returns File name with extension replaced * @returns File name with extension replaced
*/ */
private getReplacedFileName(fileName: string) { private getReplacedFileName(fileName: string, newExtension: string) {
return this.extractFilenameFromPath(fileName).replace( return this.extractFilenameFromPath(fileName).replace(
new RegExp(`\\.${this.fromFormat}$`), /\.[^/.]+$/,
`.${this.toFormat}`, `.${newExtension}`,
); );
} }
@ -63,32 +64,44 @@ export class MediaConverter {
* @param media Media to convert * @param media Media to convert
* @returns Converted media * @returns Converted media
*/ */
public async convert(media: File) { public async convert(
if (!this.isConvertable()) { media: File,
toMime: (typeof supportedMediaFormats)[number],
) {
if (!this.isConvertable(media)) {
return 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()); const sharpCommand = sharp(await media.arrayBuffer());
// Calculate newFilename before changing formats to prevent errors with jpg files const commandName = toMime.split("/")[1] as
const newFilename = this.getReplacedFileName(media.name); | "jpeg"
| "png"
| "webp"
| "avif"
| "gif"
| "tiff";
if (this.fromFormat === ConvertableMediaFormats.JPG) { const convertedBuffer = await sharpCommand[commandName]().toBuffer();
this.fromFormat = ConvertableMediaFormats.JPEG;
}
if (this.toFormat === ConvertableMediaFormats.JPG) {
this.toFormat = ConvertableMediaFormats.JPEG;
}
const convertedBuffer = await sharpCommand[this.toFormat]().toBuffer();
// Convert the buffer to a BlobPart // Convert the buffer to a BlobPart
const buffer = new Blob([convertedBuffer]); const buffer = new Blob([convertedBuffer]);
return new File([buffer], newFilename, { return new File(
type: `image/${this.toFormat}`, [buffer],
this.getReplacedFileName(media.name || "image", commandName),
{
type: toMime,
lastModified: Date.now(), lastModified: Date.now(),
}); },
);
} }
} }

View file

@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test"; import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test";
import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager"; import type { Config } from "config-manager";
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/backends/s3.test.ts
import { import {
LocalMediaBackend, LocalMediaBackend,
MediaBackend, MediaBackend,
@ -9,7 +8,7 @@ import {
MediaHasher, MediaHasher,
S3MediaBackend, S3MediaBackend,
} from ".."; } from "..";
import { ConvertableMediaFormats, MediaConverter } from "../media-converter"; import { MediaConverter } from "../media-converter";
type DeepPartial<T> = { type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>; [P in keyof T]?: DeepPartial<T[P]>;
@ -115,7 +114,7 @@ describe("S3MediaBackend", () => {
}, },
media: { media: {
conversion: { conversion: {
convert_to: ConvertableMediaFormats.PNG, convert_to: "image/png",
}, },
}, },
}; };
@ -201,7 +200,7 @@ describe("LocalMediaBackend", () => {
media: { media: {
conversion: { conversion: {
convert_images: true, convert_images: true,
convert_to: ConvertableMediaFormats.PNG, convert_to: "image/png",
}, },
local_uploads_folder: "./uploads", local_uploads_folder: "./uploads",
}, },
@ -220,10 +219,7 @@ describe("LocalMediaBackend", () => {
it("should add file", async () => { it("should add file", async () => {
const mockHash = "test-hash"; const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
const mockMediaConverter = new MediaConverter( const mockMediaConverter = new MediaConverter();
ConvertableMediaFormats.JPG,
ConvertableMediaFormats.PNG,
);
spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile); spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile);
// @ts-expect-error This is a mock // @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({ spyOn(Bun, "file").mockImplementationOnce(() => ({

View file

@ -1,33 +1,20 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts // FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts
import { beforeEach, describe, expect, it } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { ConvertableMediaFormats, MediaConverter } from "../media-converter"; import { MediaConverter } from "../media-converter";
describe("MediaConverter", () => { describe("MediaConverter", () => {
let mediaConverter: MediaConverter; let mediaConverter: MediaConverter;
beforeEach(() => { beforeEach(() => {
mediaConverter = new MediaConverter( 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", () => { it("should replace file name extension", () => {
const fileName = "test.jpg"; const fileName = "test.jpg";
const expectedFileName = "test.png"; const expectedFileName = "test.png";
// Written like this because it's a private function // Written like this because it's a private function
expect(mediaConverter.getReplacedFileName(fileName)).toEqual( // @ts-ignore
expect(mediaConverter.getReplacedFileName(fileName, "png")).toEqual(
expectedFileName, expectedFileName,
); );
}); });
@ -36,6 +23,7 @@ describe("MediaConverter", () => {
it("should extract filename from path", () => { it("should extract filename from path", () => {
const path = "path/to/test.jpg"; const path = "path/to/test.jpg";
const expectedFileName = "test.jpg"; const expectedFileName = "test.jpg";
// @ts-ignore
expect(mediaConverter.extractFilenameFromPath(path)).toEqual( expect(mediaConverter.extractFilenameFromPath(path)).toEqual(
expectedFileName, expectedFileName,
); );
@ -44,6 +32,7 @@ describe("MediaConverter", () => {
it("should handle escaped slashes", () => { it("should handle escaped slashes", () => {
const path = "path/to/test\\/test.jpg"; const path = "path/to/test\\/test.jpg";
const expectedFileName = "test\\/test.jpg"; const expectedFileName = "test\\/test.jpg";
// @ts-ignore
expect(mediaConverter.extractFilenameFromPath(path)).toEqual( expect(mediaConverter.extractFilenameFromPath(path)).toEqual(
expectedFileName, expectedFileName,
); );
@ -55,11 +44,10 @@ describe("MediaConverter", () => {
const convertedFile = await mediaConverter.convert( const convertedFile = await mediaConverter.convert(
file as unknown as File, file as unknown as File,
"image/png",
); );
expect(convertedFile.name).toEqual("megamind.png"); expect(convertedFile.name).toEqual("megamind.png");
expect(convertedFile.type).toEqual( expect(convertedFile.type).toEqual("image/png");
`image/${ConvertableMediaFormats.PNG}`,
);
}); });
}); });

View file

@ -85,15 +85,17 @@ export default (app: Hono) =>
: await mimeLookup(element); : await mimeLookup(element);
if (!contentType.startsWith("image/")) { if (!contentType.startsWith("image/")) {
return jsonResponse( return errorResponse(
{ `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
},
422, 422,
); );
} }
if (element instanceof File) { if (element instanceof File) {
if (!element.name) {
return errorResponse("File must have a name", 422);
}
const media = await MediaBackend.fromBackendType( const media = await MediaBackend.fromBackendType(
config.media.backend, config.media.backend,
config, config,