mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
refactor(api): 🔥 Refactor media uploader code
This commit is contained in:
parent
9566387273
commit
303928f960
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(() => ({
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue