mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 13:59:16 +01:00
refactor: ♻️ Rewrite media management code
This commit is contained in:
parent
d09f74e58a
commit
faf829437d
34 changed files with 1195 additions and 904 deletions
118
classes/media/drivers/disk.test.ts
Normal file
118
classes/media/drivers/disk.test.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
91
classes/media/drivers/disk.ts
Normal file
91
classes/media/drivers/disk.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
43
classes/media/drivers/media-driver.ts
Normal file
43
classes/media/drivers/media-driver.ts
Normal 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>;
|
||||
}
|
||||
101
classes/media/drivers/s3.test.ts
Normal file
101
classes/media/drivers/s3.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
93
classes/media/drivers/s3.ts
Normal file
93
classes/media/drivers/s3.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue