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

@ -1,6 +1,7 @@
import { proxyUrl } from "@/response";
import type { ContentFormat } from "@lysand-org/federation/types";
import { config } from "config-manager";
import { MediaBackendType } from "config-manager/config.type";
import {
type InferInsertModel,
type InferSelectModel,
@ -9,7 +10,6 @@ import {
eq,
inArray,
} from "drizzle-orm";
import { MediaBackendType } from "media-manager";
import { db } from "~/drizzle/db";
import { Attachments } from "~/drizzle/schema";
import type { AsyncAttachment as APIAsyncAttachment } from "~/types/mastodon/async_attachment";

Binary file not shown.

View file

@ -1,2 +0,0 @@
[install.scopes]
"@jsr" = "https://npm.jsr.io"

View file

@ -1,272 +0,0 @@
import { rm } from "node:fs/promises";
import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager";
import { MediaConverter } from "./media-converter";
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(
public config: Config,
public backend: MediaBackendType,
) {}
public static fromBackendType(
backend: MediaBackendType,
config: Config,
): MediaBackend {
switch (backend) {
case MediaBackendType.Local:
return new LocalMediaBackend(config);
case MediaBackendType.S3:
return new S3MediaBackend(config);
default:
throw new Error(`Unknown backend type: ${backend as string}`);
}
}
public getBackendType() {
return this.backend;
}
public shouldConvertImages(config: Config) {
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(
_file: string,
_databaseHashFetcher: (sha256: string) => Promise<string>,
): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
);
}
public deleteFileByUrl(_url: string): Promise<void> {
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
*/
public getFile(_filename: string): Promise<File | null> {
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
*/
public addFile(_file: File): Promise<UploadedFileMetadata> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
);
}
}
export class LocalMediaBackend extends MediaBackend {
constructor(config: Config) {
super(config, MediaBackendType.Local);
}
public async addFile(file: File) {
let convertedFile = file;
if (this.shouldConvertImages(this.config)) {
const mediaConverter = new MediaConverter();
convertedFile = await mediaConverter.convert(
file,
this.config.media.conversion.convert_to,
);
}
const hash = await new MediaHasher().getMediaHash(convertedFile);
const newFile = Bun.file(
`${this.config.media.local_uploads_folder}/${hash}/${convertedFile.name}`,
);
if (await newFile.exists()) {
// Already exists, we don't need to upload it again
return {
uploadedFile: convertedFile,
path: `${hash}/${convertedFile.name}`,
hash: hash,
};
}
await Bun.write(newFile, convertedFile);
return {
uploadedFile: convertedFile,
path: `${hash}/${convertedFile.name}`,
hash: hash,
};
}
public async deleteFileByUrl(url: string) {
// url is of format https://base-url/media/SHA256HASH/FILENAME
const urlO = new URL(url);
const hash = urlO.pathname.split("/")[1];
const dirPath = `${this.config.media.local_uploads_folder}/${hash}`;
try {
await rm(dirPath, { recursive: true });
} catch (e) {
console.error(`Failed to delete directory at ${dirPath}`);
console.error(e);
}
return;
}
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);
}
public async getFile(filename: string): Promise<File | null> {
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,
});
}
}
export class S3MediaBackend extends MediaBackend {
constructor(
config: Config,
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(config, MediaBackendType.S3);
}
public async deleteFileByUrl(url: string) {
// url is of format https://s3-base-url/SHA256HASH/FILENAME
const urlO = new URL(url);
const hash = urlO.pathname.split("/")[1];
const filename = urlO.pathname.split("/")[2];
await this.s3Client.deleteObject(`${hash}/${filename}`);
return;
}
public async addFile(file: File) {
let convertedFile = file;
if (this.shouldConvertImages(this.config)) {
const mediaConverter = new MediaConverter();
convertedFile = await mediaConverter.convert(
file,
this.config.media.conversion.convert_to,
);
}
const hash = await new MediaHasher().getMediaHash(convertedFile);
await this.s3Client.putObject(
`${hash}/${convertedFile.name}`,
convertedFile.stream(),
{
size: convertedFile.size,
},
);
return {
uploadedFile: convertedFile,
path: `${hash}/${convertedFile.name}`,
hash: hash,
};
}
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);
}
public async getFile(filename: string): Promise<File | null> {
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",
});
}
}
export { MediaConverter };

View file

@ -1,117 +0,0 @@
/**
* @packageDocumentation
* @module MediaManager
* @description Handles media conversion between formats
*/
import { config } from "config-manager";
import sharp from "sharp";
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 {
/**
* Returns whether the media is convertable
* @returns Whether the media is convertable
*/
public isConvertable(file: File) {
if (
file.type === "image/svg+xml" &&
!config.media.conversion.convert_vector
) {
return false;
}
return supportedMediaFormats.includes(file.type);
}
/**
* Returns the file name with the extension replaced
* @param fileName File name to replace
* @returns File name with extension replaced
*/
private getReplacedFileName(fileName: string, newExtension: string) {
return this.extractFilenameFromPath(fileName).replace(
/\.[^/.]+$/,
`.${newExtension}`,
);
}
/**
* 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(/(?<!\\)\//);
return pathParts[pathParts.length - 1];
}
/**
* Converts media to the specified format
* @param media Media to convert
* @returns Converted media
*/
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(), {
animated: true,
});
const commandName = toMime.split("/")[1] as
| "jpeg"
| "png"
| "webp"
| "avif"
| "gif"
| "tiff";
const convertedBuffer = await sharpCommand[commandName]().toBuffer();
// Convert the buffer to a BlobPart
const buffer = new Blob([convertedBuffer]);
return new File(
[buffer],
this.getReplacedFileName(media.name || "image", commandName),
{
type: toMime,
lastModified: Date.now(),
},
);
}
}

View file

@ -1,9 +0,0 @@
{
"name": "media-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {
"@jsr/bradenmacdonald__s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client",
"config-manager": "workspace:*"
}
}

View file

@ -1,317 +0,0 @@
import { beforeEach, describe, expect, it, jest, mock, spyOn } from "bun:test";
import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager";
import {
LocalMediaBackend,
MediaBackend,
MediaBackendType,
MediaHasher,
S3MediaBackend,
} from "../index";
import { MediaConverter } from "../media-converter";
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
describe("MediaBackend", () => {
let mediaBackend: MediaBackend;
let mockConfig: Config;
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
},
},
} as Config;
mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3);
});
it("should initialize with correct backend type", () => {
expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
});
describe("fromBackendType", () => {
it("should return a LocalMediaBackend instance for LOCAL backend type", async () => {
const backend = await MediaBackend.fromBackendType(
MediaBackendType.Local,
mockConfig,
);
expect(backend).toBeInstanceOf(LocalMediaBackend);
});
it("should return a S3MediaBackend instance for S3 backend type", async () => {
const backend = await MediaBackend.fromBackendType(
MediaBackendType.S3,
{
s3: {
endpoint: "localhost:4566",
region: "us-east-1",
bucket_name: "test-bucket",
access_key: "test-access",
public_url: "test",
secret_access_key: "test-secret",
},
} as Config,
);
expect(backend).toBeInstanceOf(S3MediaBackend);
});
it("should throw an error for unknown backend type", () => {
expect(
// @ts-expect-error This is a test
() => MediaBackend.fromBackendType("unknown", mockConfig),
).toThrow("Unknown backend type: unknown");
});
});
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<S3Client>;
let mockConfig: DeepPartial<Config>;
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: "image/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" }),
}),
deleteObject: jest.fn().mockResolvedValue({}),
} as Partial<S3Client>;
s3MediaBackend = new S3MediaBackend(
mockConfig as Config,
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(
expect.stringContaining(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");
});
it("should delete file", async () => {
// deleteFileByUrl
// Upload file first
const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
const result = await s3MediaBackend.addFile(mockFile);
const url = result.path;
await s3MediaBackend.deleteFileByUrl(`http://localhost:4566/${url}`);
expect(mockS3Client.deleteObject).toHaveBeenCalledWith(
expect.stringContaining(url),
);
});
});
describe("LocalMediaBackend", () => {
let localMediaBackend: LocalMediaBackend;
let mockConfig: Config;
let mockFile: File;
let mockMediaHasher: MediaHasher;
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
convert_to: "image/png",
},
local_uploads_folder: "./uploads",
},
} as Config;
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();
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(expect.stringContaining("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");
});
it("should delete file", async () => {
// deleteByUrl
const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
await localMediaBackend.addFile(mockFile);
const rmMock = jest.fn().mockResolvedValue(Promise.resolve());
// Spy on fs/promises rm
mock.module("fs/promises", () => {
return {
rm: rmMock,
};
});
await localMediaBackend.deleteFileByUrl(
"http://localhost:4566/test-hash",
);
expect(rmMock).toHaveBeenCalledWith(
`${mockConfig.media.local_uploads_folder}/${mockHash}`,
{ recursive: true },
);
});
});

View file

@ -1,53 +0,0 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts
import { beforeEach, describe, expect, it } from "bun:test";
import { MediaConverter } from "../media-converter";
describe("MediaConverter", () => {
let mediaConverter: MediaConverter;
beforeEach(() => {
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
// @ts-ignore
expect(mediaConverter.getReplacedFileName(fileName, "png")).toEqual(
expectedFileName,
);
});
describe("Filename extractor", () => {
it("should extract filename from path", () => {
const path = "path/to/test.jpg";
const expectedFileName = "test.jpg";
// @ts-ignore
expect(mediaConverter.extractFilenameFromPath(path)).toEqual(
expectedFileName,
);
});
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,
);
});
});
it("should convert media", async () => {
const file = Bun.file(`${__dirname}/megamind.jpg`);
const convertedFile = await mediaConverter.convert(
file as unknown as File,
"image/png",
);
expect(convertedFile.name).toEqual("megamind.png");
expect(convertedFile.type).toEqual("image/png");
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB