refactor: ♻️ Rewrite media management code

This commit is contained in:
Jesse Wierzbinski 2024-06-28 20:10:02 -10:00
parent d09f74e58a
commit faf829437d
No known key found for this signature in database
34 changed files with 1195 additions and 904 deletions

View file

@ -0,0 +1,118 @@
/**
* @packageDocumentation
* @module Tests/DiskMediaDriver
*/
import {
type Mock,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from "bun:test";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { Config } from "config-manager";
import type { MediaHasher } from "../media-hasher";
import { DiskMediaDriver } from "./disk";
describe("DiskMediaDriver", () => {
let diskDriver: DiskMediaDriver;
let mockConfig: Config;
let mockMediaHasher: MediaHasher;
let bunWriteSpy: Mock<typeof Bun.write>;
beforeEach(() => {
mockConfig = {
media: {
local_uploads_folder: "/test/uploads",
},
} as Config;
mockMediaHasher = mock(() => ({
getMediaHash: mock(() => Promise.resolve("testhash")),
}))();
diskDriver = new DiskMediaDriver(mockConfig);
// @ts-ignore: Replacing private property for testing
diskDriver.mediaHasher = mockMediaHasher;
// Mock fs.promises methods
mock.module("node:fs/promises", () => ({
writeFile: mock(() => Promise.resolve()),
rm: mock(() => {
return Promise.resolve();
}),
}));
spyOn(Bun, "file").mockImplementation(
mock(() => ({
exists: mock(() => Promise.resolve(true)),
arrayBuffer: mock(() => Promise.resolve(new ArrayBuffer(8))),
type: "image/webp",
lastModified: Date.now(),
})) as unknown as typeof Bun.file,
);
bunWriteSpy = spyOn(Bun, "write").mockImplementation(
mock(() => Promise.resolve(0)),
);
});
it("should add a file", async () => {
const file = new File(["test"], "test.webp", { type: "image/webp" });
const result = await diskDriver.addFile(file);
expect(mockMediaHasher.getMediaHash).toHaveBeenCalledWith(file);
expect(bunWriteSpy).toHaveBeenCalledWith(
path.join("/test/uploads", "testhash", "test.webp"),
expect.any(ArrayBuffer),
);
expect(result).toEqual({
uploadedFile: file,
path: path.join("testhash", "test.webp"),
hash: "testhash",
});
});
it("should get a file by hash", async () => {
const hash = "testhash";
const databaseHashFetcher = mock(() => Promise.resolve("test.webp"));
const result = await diskDriver.getFileByHash(
hash,
databaseHashFetcher,
);
expect(databaseHashFetcher).toHaveBeenCalledWith(hash);
expect(Bun.file).toHaveBeenCalledWith(
path.join("/test/uploads", "test.webp"),
);
expect(result).toBeInstanceOf(File);
expect(result?.name).toBe("test.webp");
expect(result?.type).toBe("image/webp");
});
it("should get a file by filename", async () => {
const filename = "test.webp";
const result = await diskDriver.getFile(filename);
expect(Bun.file).toHaveBeenCalledWith(
path.join("/test/uploads", filename),
);
expect(result).toBeInstanceOf(File);
expect(result?.name).toBe(filename);
expect(result?.type).toBe("image/webp");
});
it("should delete a file by URL", async () => {
const url = "http://localhost:3000/uploads/testhash/test.webp";
await diskDriver.deleteFileByUrl(url);
expect(fs.rm).toHaveBeenCalledWith(
path.join("/test/uploads", "testhash"),
{ recursive: true },
);
});
});

View file

@ -0,0 +1,91 @@
/**
* @packageDocumentation
* @module MediaManager/Drivers
*/
import { rm } from "node:fs/promises";
import { join } from "node:path";
import type { Config } from "config-manager";
import { MediaHasher } from "../media-hasher";
import type { UploadedFileMetadata } from "../media-manager";
import type { MediaDriver } from "./media-driver";
/**
* Implements the MediaDriver interface for disk storage.
*/
export class DiskMediaDriver implements MediaDriver {
private mediaHasher: MediaHasher;
/**
* Creates a new DiskMediaDriver instance.
* @param config - The configuration object.
*/
constructor(private config: Config) {
this.mediaHasher = new MediaHasher();
}
/**
* @inheritdoc
*/
public async addFile(
file: File,
): Promise<Omit<UploadedFileMetadata, "blurhash">> {
const hash = await this.mediaHasher.getMediaHash(file);
const path = join(hash, file.name);
const fullPath = join(this.config.media.local_uploads_folder, path);
await Bun.write(fullPath, await file.arrayBuffer());
return {
uploadedFile: file,
path,
hash,
};
}
/**
* @inheritdoc
*/
public async getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>,
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
if (!filename) {
return null;
}
return this.getFile(filename);
}
/**
* @inheritdoc
*/
public async getFile(filename: string): Promise<File | null> {
const fullPath = join(this.config.media.local_uploads_folder, filename);
try {
const file = Bun.file(fullPath);
if (await file.exists()) {
return new File([await file.arrayBuffer()], filename, {
type: file.type,
lastModified: file.lastModified,
});
}
} catch {
// File doesn't exist or can't be read
}
return null;
}
/**
* @inheritdoc
*/
public async deleteFileByUrl(url: string): Promise<void> {
const urlObj = new URL(url);
const hash = urlObj.pathname.split("/").at(-2);
if (!hash) {
throw new Error("Invalid URL");
}
const dirPath = join(this.config.media.local_uploads_folder, hash);
await rm(dirPath, { recursive: true });
}
}

View file

@ -0,0 +1,43 @@
/**
* @packageDocumentation
* @module MediaManager/Drivers
*/
import type { UploadedFileMetadata } from "../media-manager";
/**
* Represents a media storage driver.
*/
export interface MediaDriver {
/**
* Adds a file to the media storage.
* @param file - The file to add.
* @returns A promise that resolves to the metadata of the uploaded file.
*/
addFile(file: File): Promise<Omit<UploadedFileMetadata, "blurhash">>;
/**
* Retrieves a file from the media storage by its hash.
* @param hash - The hash of the file to retrieve.
* @param databaseHashFetcher - A function to fetch the filename from the database.
* @returns A promise that resolves to the file or null if not found.
*/
getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>,
): Promise<File | null>;
/**
* Retrieves a file from the media storage by its filename.
* @param filename - The name of the file to retrieve.
* @returns A promise that resolves to the file or null if not found.
*/
getFile(filename: string): Promise<File | null>;
/**
* Deletes a file from the media storage by its URL.
* @param url - The URL of the file to delete.
* @returns A promise that resolves when the file is deleted.
*/
deleteFileByUrl(url: string): Promise<void>;
}

View file

@ -0,0 +1,101 @@
/**
* @packageDocumentation
* @module Tests/S3MediaDriver
*/
import { beforeEach, describe, expect, it, mock } from "bun:test";
import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager";
import type { MediaHasher } from "../media-hasher";
import { S3MediaDriver } from "./s3";
describe("S3MediaDriver", () => {
let s3Driver: S3MediaDriver;
let mockConfig: Config;
let mockS3Client: S3Client;
let mockMediaHasher: MediaHasher;
beforeEach(() => {
mockConfig = {
s3: {
endpoint: "s3.amazonaws.com",
region: "us-west-2",
bucket_name: "test-bucket",
access_key: "test-key",
secret_access_key: "test-secret",
},
} as Config;
mockS3Client = mock(() => ({
putObject: mock(() => Promise.resolve()),
getObject: mock(() =>
Promise.resolve({
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
headers: new Headers({ "Content-Type": "image/webp" }),
}),
),
statObject: mock(() => Promise.resolve()),
deleteObject: mock(() => Promise.resolve()),
}))() as unknown as S3Client;
mockMediaHasher = mock(() => ({
getMediaHash: mock(() => Promise.resolve("testhash")),
}))();
s3Driver = new S3MediaDriver(mockConfig);
// @ts-ignore: Replacing private property for testing
s3Driver.s3Client = mockS3Client;
// @ts-ignore: Replacing private property for testing
s3Driver.mediaHasher = mockMediaHasher;
});
it("should add a file", async () => {
const file = new File(["test"], "test.webp", { type: "image/webp" });
const result = await s3Driver.addFile(file);
expect(mockMediaHasher.getMediaHash).toHaveBeenCalledWith(file);
expect(mockS3Client.putObject).toHaveBeenCalledWith(
"testhash/test.webp",
expect.any(ReadableStream),
{ size: file.size },
);
expect(result).toEqual({
uploadedFile: file,
path: "testhash/test.webp",
hash: "testhash",
});
});
it("should get a file by hash", async () => {
const hash = "testhash";
const databaseHashFetcher = mock(() => Promise.resolve("test.webp"));
const result = await s3Driver.getFileByHash(hash, databaseHashFetcher);
expect(databaseHashFetcher).toHaveBeenCalledWith(hash);
expect(mockS3Client.statObject).toHaveBeenCalledWith("test.webp");
expect(mockS3Client.getObject).toHaveBeenCalledWith("test.webp");
expect(result).toBeInstanceOf(File);
expect(result?.name).toBe("test.webp");
expect(result?.type).toBe("image/webp");
});
it("should get a file by filename", async () => {
const filename = "test.webp";
const result = await s3Driver.getFile(filename);
expect(mockS3Client.statObject).toHaveBeenCalledWith(filename);
expect(mockS3Client.getObject).toHaveBeenCalledWith(filename);
expect(result).toBeInstanceOf(File);
expect(result?.name).toBe(filename);
expect(result?.type).toBe("image/webp");
});
it("should delete a file by URL", async () => {
const url = "https://test-bucket.s3.amazonaws.com/test/test.webp";
await s3Driver.deleteFileByUrl(url);
expect(mockS3Client.deleteObject).toHaveBeenCalledWith(
"test/test.webp",
);
});
});

View file

@ -0,0 +1,93 @@
/**
* @packageDocumentation
* @module MediaManager/Drivers
*/
import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager";
import { MediaHasher } from "../media-hasher";
import type { UploadedFileMetadata } from "../media-manager";
import type { MediaDriver } from "./media-driver";
/**
* Implements the MediaDriver interface for S3 storage.
*/
export class S3MediaDriver implements MediaDriver {
private s3Client: S3Client;
private mediaHasher: MediaHasher;
/**
* Creates a new S3MediaDriver instance.
* @param config - The configuration object.
*/
constructor(config: Config) {
this.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,
});
this.mediaHasher = new MediaHasher();
}
/**
* @inheritdoc
*/
public async addFile(
file: File,
): Promise<Omit<UploadedFileMetadata, "blurhash">> {
const hash = await this.mediaHasher.getMediaHash(file);
const path = `${hash}/${file.name}`;
await this.s3Client.putObject(path, file.stream(), {
size: file.size,
});
return {
uploadedFile: file,
path,
hash,
};
}
/**
* @inheritdoc
*/
public async getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>,
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
if (!filename) {
return null;
}
return this.getFile(filename);
}
/**
* @inheritdoc
*/
public async getFile(filename: string): Promise<File | null> {
try {
await this.s3Client.statObject(filename);
const file = await this.s3Client.getObject(filename);
const arrayBuffer = await file.arrayBuffer();
return new File([arrayBuffer], filename, {
type: file.headers.get("Content-Type") || undefined,
});
} catch {
return null;
}
}
/**
* @inheritdoc
*/
public async deleteFileByUrl(url: string): Promise<void> {
const urlObj = new URL(url);
const path = urlObj.pathname.slice(1); // Remove leading slash
await this.s3Client.deleteObject(path);
}
}

View file

@ -0,0 +1,20 @@
/**
* @packageDocumentation
* @module MediaManager/Utils
*/
/**
* Utility class for hashing media files.
*/
export class MediaHasher {
/**
* Generates a SHA-256 hash for a given file.
* @param file - The file to hash.
* @returns A promise that resolves to the SHA-256 hash of the file in hex format.
*/
public async getMediaHash(file: File): Promise<string> {
const arrayBuffer = await file.arrayBuffer();
const hash = new Bun.SHA256().update(arrayBuffer).digest("hex");
return hash;
}
}

View file

@ -0,0 +1,124 @@
/**
* @packageDocumentation
* @module Tests/MediaManager
*/
import { beforeEach, describe, expect, it, mock } from "bun:test";
import type { Config } from "config-manager";
import { MediaBackendType } from "~/packages/config-manager/config.type";
import { DiskMediaDriver } from "./drivers/disk";
import { S3MediaDriver } from "./drivers/s3";
import { MediaManager } from "./media-manager";
import type { ImageConversionPreprocessor } from "./preprocessors/image-conversion";
describe("MediaManager", () => {
let mediaManager: MediaManager;
let mockConfig: Config;
let mockS3Driver: S3MediaDriver;
let mockImagePreprocessor: ImageConversionPreprocessor;
beforeEach(() => {
mockConfig = {
media: {
backend: "s3",
conversion: {
convert_images: true,
convert_to: "image/webp",
},
},
s3: {
endpoint: "s3.amazonaws.com",
region: "us-west-2",
bucket_name: "test-bucket",
access_key: "test-key",
secret_access_key: "test-secret",
},
} as Config;
mockS3Driver = mock(() => ({
addFile: mock(() =>
Promise.resolve({
uploadedFile: new File(["hey"], "test.webp"),
path: "test/test.webp",
hash: "testhash",
}),
),
getFileByHash: mock(() => {
return Promise.resolve(new File(["hey"], "test.webp"));
}),
getFile: mock(() =>
Promise.resolve(new File(["hey"], "test.webp")),
),
deleteFileByUrl: mock(() => Promise.resolve()),
}))() as unknown as S3MediaDriver;
mockImagePreprocessor = mock(() => ({
process: mock((_: File) =>
Promise.resolve(new File(["hey"], "test.webp")),
),
}))() as unknown as ImageConversionPreprocessor;
mediaManager = new MediaManager(mockConfig);
// @ts-expect-error: Accessing private property for testing
mediaManager.driver = mockS3Driver;
// @ts-expect-error: Accessing private property for testing
mediaManager.preprocessors = [mockImagePreprocessor];
});
it("should initialize with the correct driver based on config", () => {
const s3Manager = new MediaManager(mockConfig);
// @ts-expect-error: Accessing private property for testing
expect(s3Manager.driver).toBeInstanceOf(S3MediaDriver);
mockConfig.media.backend = MediaBackendType.Local;
const diskManager = new MediaManager(mockConfig);
// @ts-expect-error: Accessing private property for testing
expect(diskManager.driver).toBeInstanceOf(DiskMediaDriver);
});
it("should add a file with preprocessing", async () => {
const file = new File(["test"], "test.jpg", { type: "image/jpeg" });
const result = await mediaManager.addFile(file);
expect(mockImagePreprocessor.process).toHaveBeenCalledWith(file);
expect(mockS3Driver.addFile).toHaveBeenCalled();
expect(result).toEqual({
uploadedFile: new File(["hey"], "test.webp"),
path: "test/test.webp",
hash: "testhash",
blurhash: null,
});
});
it("should get a file by hash", async () => {
const hash = "testhash";
const databaseHashFetcher = mock(() => Promise.resolve("test.webp"));
const result = await mediaManager.getFileByHash(
hash,
databaseHashFetcher,
);
expect(mockS3Driver.getFileByHash).toHaveBeenCalledWith(
hash,
databaseHashFetcher,
);
expect(result).toBeInstanceOf(File);
expect(result?.name).toBe("test.webp");
});
it("should get a file by filename", async () => {
const filename = "test.webp";
const result = await mediaManager.getFile(filename);
expect(mockS3Driver.getFile).toHaveBeenCalledWith(filename);
expect(result).toBeInstanceOf(File);
expect(result?.name).toBe("test.webp");
});
it("should delete a file by URL", async () => {
const url = "https://test-bucket.s3.amazonaws.com/test/test.webp";
await mediaManager.deleteFileByUrl(url);
expect(mockS3Driver.deleteFileByUrl).toHaveBeenCalledWith(url);
});
});

View file

@ -0,0 +1,132 @@
/**
* @packageDocumentation
* @module MediaManager
*/
import type { Config } from "config-manager";
import { DiskMediaDriver } from "./drivers/disk";
import type { MediaDriver } from "./drivers/media-driver";
import { S3MediaDriver } from "./drivers/s3";
import { BlurhashPreprocessor } from "./preprocessors/blurhash";
import { ImageConversionPreprocessor } from "./preprocessors/image-conversion";
import type { MediaPreprocessor } from "./preprocessors/media-preprocessor";
/**
* Manages media operations with support for different storage drivers and preprocessing plugins.
* @example
* const mediaManager = new MediaManager(config);
*
* const file = new File(["hello"], "hello.txt");
*
* const { path, hash, blurhash } = await mediaManager.addFile(file);
*
* const retrievedFile = await mediaManager.getFileByHash(hash, fetchHashFromDatabase);
*
* await mediaManager.deleteFileByUrl(path);
*/
export class MediaManager {
private driver: MediaDriver;
private preprocessors: MediaPreprocessor[] = [];
/**
* Creates a new MediaManager instance.
* @param config - The configuration object.
*/
constructor(private config: Config) {
this.driver = this.initializeDriver();
this.initializePreprocessors();
}
/**
* Initializes the appropriate media driver based on the configuration.
* @returns An instance of MediaDriver.
*/
private initializeDriver(): MediaDriver {
switch (this.config.media.backend) {
case "s3":
return new S3MediaDriver(this.config);
case "local":
return new DiskMediaDriver(this.config);
default:
throw new Error(
`Unsupported media backend: ${this.config.media.backend}`,
);
}
}
/**
* Initializes the preprocessors based on the configuration.
*/
private initializePreprocessors(): void {
if (this.config.media.conversion.convert_images) {
this.preprocessors.push(
new ImageConversionPreprocessor(this.config),
);
}
this.preprocessors.push(new BlurhashPreprocessor());
// Add other preprocessors here as needed
}
/**
* Adds a file to the media storage.
* @param file - The file to add.
* @returns A promise that resolves to the metadata of the uploaded file.
*/
public async addFile(file: File): Promise<UploadedFileMetadata> {
let processedFile = file;
let blurhash: string | null = null;
for (const preprocessor of this.preprocessors) {
const result = await preprocessor.process(processedFile);
if ("blurhash" in result) {
blurhash = result.blurhash as string;
processedFile = result.file;
} else {
processedFile = result.file;
}
}
const uploadResult = await this.driver.addFile(processedFile);
return { ...uploadResult, blurhash };
}
/**
* Retrieves a file from the media storage by its hash.
* @param hash - The hash of the file to retrieve.
* @param databaseHashFetcher - A function to fetch the filename from the database.
* @returns A promise that resolves to the file or null if not found.
*/
public getFileByHash(
hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null>,
): Promise<File | null> {
return this.driver.getFileByHash(hash, databaseHashFetcher);
}
/**
* Retrieves a file from the media storage by its filename.
* @param filename - The name of the file to retrieve.
* @returns A promise that resolves to the file or null if not found.
*/
public getFile(filename: string): Promise<File | null> {
return this.driver.getFile(filename);
}
/**
* Deletes a file from the media storage by its URL.
* @param url - The URL of the file to delete.
* @returns A promise that resolves when the file is deleted.
*/
public deleteFileByUrl(url: string): Promise<void> {
return this.driver.deleteFileByUrl(url);
}
}
/**
* Represents the metadata of an uploaded file.
*/
export interface UploadedFileMetadata {
uploadedFile: File;
path: string;
hash: string;
blurhash: string | null;
}

View file

@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
import sharp from "sharp";
import { BlurhashPreprocessor } from "./blurhash";
describe("BlurhashPreprocessor", () => {
let preprocessor: BlurhashPreprocessor;
beforeEach(() => {
preprocessor = new BlurhashPreprocessor();
});
it("should calculate blurhash for a valid image", async () => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const inputFile = new File([inputBuffer], "test.png", {
type: "image/png",
});
const result = await preprocessor.process(inputFile);
expect(result.file).toBe(inputFile);
expect(result.blurhash).toBeTypeOf("string");
expect(result.blurhash).not.toBe("");
});
it("should return null blurhash for an invalid image", async () => {
const invalidFile = new File(["invalid image data"], "invalid.png", {
type: "image/png",
});
const result = await preprocessor.process(invalidFile);
expect(result.file).toBe(invalidFile);
expect(result.blurhash).toBeNull();
});
it("should handle errors during blurhash calculation", async () => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const inputFile = new File([inputBuffer], "test.png", {
type: "image/png",
});
mock.module("blurhash", () => ({
encode: () => {
throw new Error("Test error");
},
}));
const result = await preprocessor.process(inputFile);
expect(result.file).toBe(inputFile);
expect(result.blurhash).toBeNull();
});
});

View file

@ -0,0 +1,45 @@
import { encode } from "blurhash";
import sharp from "sharp";
import type { MediaPreprocessor } from "./media-preprocessor";
export class BlurhashPreprocessor implements MediaPreprocessor {
public async process(
file: File,
): Promise<{ file: File; blurhash: string | null }> {
try {
const arrayBuffer = await file.arrayBuffer();
const metadata = await sharp(arrayBuffer).metadata();
const blurhash = await new Promise<string | null>((resolve) => {
(async () =>
sharp(arrayBuffer)
.raw()
.ensureAlpha()
.toBuffer((err, buffer) => {
if (err) {
resolve(null);
return;
}
try {
resolve(
encode(
new Uint8ClampedArray(buffer),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4,
) as string,
);
} catch {
resolve(null);
}
}))();
});
return { file, blurhash };
} catch {
return { file, blurhash: null };
}
}
}

View file

@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it } from "bun:test";
import type { Config } from "config-manager";
import sharp from "sharp";
import { ImageConversionPreprocessor } from "./image-conversion";
describe("ImageConversionPreprocessor", () => {
let preprocessor: ImageConversionPreprocessor;
let mockConfig: Config;
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
convert_to: "image/webp",
convert_vector: false,
},
},
} as Config;
preprocessor = new ImageConversionPreprocessor(mockConfig);
});
it("should convert a JPEG image to WebP", async () => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.jpeg()
.toBuffer();
const inputFile = new File([inputBuffer], "test.jpg", {
type: "image/jpeg",
});
const result = await preprocessor.process(inputFile);
expect(result.file.type).toBe("image/webp");
expect(result.file.name).toBe("test.webp");
const resultBuffer = await result.file.arrayBuffer();
const metadata = await sharp(resultBuffer).metadata();
expect(metadata.format).toBe("webp");
});
it("should not convert SVG when convert_vector is false", async () => {
const svgContent =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
const inputFile = new File([svgContent], "test.svg", {
type: "image/svg+xml",
});
const result = await preprocessor.process(inputFile);
expect(result.file).toBe(inputFile);
});
it("should convert SVG when convert_vector is true", async () => {
mockConfig.media.conversion.convert_vector = true;
preprocessor = new ImageConversionPreprocessor(mockConfig);
const svgContent =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
const inputFile = new File([svgContent], "test.svg", {
type: "image/svg+xml",
});
const result = await preprocessor.process(inputFile);
expect(result.file.type).toBe("image/webp");
expect(result.file.name).toBe("test.webp");
});
it("should not convert unsupported file types", async () => {
const inputFile = new File(["test content"], "test.txt", {
type: "text/plain",
});
const result = await preprocessor.process(inputFile);
expect(result.file).toBe(inputFile);
});
it("should throw an error for unsupported output format", async () => {
mockConfig.media.conversion.convert_to = "image/bmp";
preprocessor = new ImageConversionPreprocessor(mockConfig);
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const inputFile = new File([inputBuffer], "test.png", {
type: "image/png",
});
await expect(preprocessor.process(inputFile)).rejects.toThrow(
"Unsupported output format: image/bmp",
);
});
it("should convert animated GIF to WebP while preserving animation", async () => {
// Create a simple animated GIF
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 1 },
},
})
.gif()
.toBuffer();
const inputFile = new File([inputBuffer], "animated.gif", {
type: "image/gif",
});
const result = await preprocessor.process(inputFile);
expect(result.file.type).toBe("image/webp");
expect(result.file.name).toBe("animated.webp");
const resultBuffer = await result.file.arrayBuffer();
const metadata = await sharp(resultBuffer).metadata();
expect(metadata.format).toBe("webp");
});
it("should handle files with spaces in the name", async () => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const inputFile = new File(
[inputBuffer],
"test image with spaces.png",
{ type: "image/png" },
);
const result = await preprocessor.process(inputFile);
expect(result.file.type).toBe("image/webp");
expect(result.file.name).toBe("test image with spaces.webp");
});
});

View file

@ -0,0 +1,122 @@
/**
* @packageDocumentation
* @module MediaManager/Preprocessors
*/
import type { Config } from "config-manager";
import sharp from "sharp";
import type { MediaPreprocessor } from "./media-preprocessor";
/**
* Supported input media formats.
*/
const supportedInputFormats = [
"image/png",
"image/jpeg",
"image/webp",
"image/avif",
"image/svg+xml",
"image/gif",
"image/tiff",
];
/**
* Supported output media formats.
*/
const supportedOutputFormats = [
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
"image/gif",
"image/tiff",
];
/**
* Implements the MediaPreprocessor interface for image conversion.
*/
export class ImageConversionPreprocessor implements MediaPreprocessor {
/**
* Creates a new ImageConversionPreprocessor instance.
* @param config - The configuration object.
*/
constructor(private config: Config) {}
/**
* @inheritdoc
*/
public async process(file: File): Promise<{ file: File }> {
if (!this.isConvertible(file)) {
return { file };
}
const targetFormat = this.config.media.conversion.convert_to;
if (!supportedOutputFormats.includes(targetFormat)) {
throw new Error(`Unsupported output format: ${targetFormat}`);
}
const sharpCommand = sharp(await file.arrayBuffer(), {
animated: true,
});
const commandName = targetFormat.split("/")[1] as
| "jpeg"
| "png"
| "webp"
| "avif"
| "gif"
| "tiff";
const convertedBuffer = await sharpCommand[commandName]().toBuffer();
return {
file: new File(
[convertedBuffer],
this.getReplacedFileName(file.name, commandName),
{
type: targetFormat,
lastModified: Date.now(),
},
),
};
}
/**
* Checks if a file is convertible.
* @param file - The file to check.
* @returns True if the file is convertible, false otherwise.
*/
private isConvertible(file: File): boolean {
if (
file.type === "image/svg+xml" &&
!this.config.media.conversion.convert_vector
) {
return false;
}
return supportedInputFormats.includes(file.type);
}
/**
* Replaces the file extension in the filename.
* @param fileName - The original filename.
* @param newExtension - The new extension.
* @returns The filename with the new extension.
*/
private getReplacedFileName(
fileName: string,
newExtension: string,
): string {
return this.extractFilenameFromPath(fileName).replace(
/\.[^/.]+$/,
`.${newExtension}`,
);
}
/**
* Extracts the filename from a path.
* @param path - The path to extract the filename from.
* @returns The extracted filename.
*/
private extractFilenameFromPath(path: string): string {
const pathParts = path.split(/(?<!\\)\//);
return pathParts[pathParts.length - 1];
}
}

View file

@ -0,0 +1,16 @@
/**
* @packageDocumentation
* @module MediaManager/Preprocessors
*/
/**
* Represents a media preprocessor.
*/
export interface MediaPreprocessor {
/**
* Processes a file before it's stored.
* @param file - The file to process.
* @returns A promise that resolves to the processed file.
*/
process(file: File): Promise<{ file: File } & Record<string, unknown>>;
}