server/classes/media.ts

274 lines
6.2 KiB
TypeScript
Raw Normal View History

2023-10-18 02:57:47 +02:00
import {
GetObjectCommand,
GetObjectCommandOutput,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { ConfigType } from "@config";
import sharp from "sharp";
import { exists, mkdir } from "fs/promises";
2023-10-18 02:57:47 +02:00
class MediaBackend {
backend: string;
constructor(backend: string) {
this.backend = backend;
}
/**
* Adds media to the media backend
* @param media
* @returns The hash of the file in SHA-256 (hex format) with the file extension added to it
2023-10-18 02:57:47 +02:00
*/
async addMedia(media: File) {
const hash = new Bun.SHA256()
.update(await media.arrayBuffer())
.digest("hex");
return `${hash}.${media.name.split(".").pop()}`;
2023-10-18 02:57:47 +02:00
}
async convertMedia(media: File, config: ConfigType) {
const sharpCommand = sharp(await media.arrayBuffer());
// Rename ".jpg" files to ".jpeg" to avoid sharp errors
let name = media.name;
if (media.name.endsWith(".jpg")) {
name = media.name.replace(".jpg", ".jpeg");
}
const fileFormatToConvertTo = config.media.conversion.convert_to;
switch (fileFormatToConvertTo) {
case "png":
return new File(
[(await sharpCommand.png().toBuffer()).buffer],
// Replace the file extension with PNG
name.replace(/\.[^/.]+$/, ".png"),
{
type: "image/png",
}
);
case "webp":
return new File(
[(await sharpCommand.webp().toBuffer()).buffer],
// Replace the file extension with WebP
name.replace(/\.[^/.]+$/, ".webp"),
{
type: "image/webp",
}
);
case "jpeg":
return new File(
[(await sharpCommand.jpeg().toBuffer()).buffer],
// Replace the file extension with JPEG
name.replace(/\.[^/.]+$/, ".jpeg"),
{
type: "image/jpeg",
}
);
case "avif":
return new File(
[(await sharpCommand.avif().toBuffer()).buffer],
// Replace the file extension with AVIF
name.replace(/\.[^/.]+$/, ".avif"),
{
type: "image/avif",
}
);
// Needs special build of libvips
case "jxl":
return new File(
[(await sharpCommand.jxl().toBuffer()).buffer],
// Replace the file extension with JXL
name.replace(/\.[^/.]+$/, ".jxl"),
{
type: "image/jxl",
}
);
case "heif":
return new File(
[(await sharpCommand.heif().toBuffer()).buffer],
// Replace the file extension with HEIF
name.replace(/\.[^/.]+$/, ".heif"),
{
type: "image/heif",
}
);
default:
return media;
}
}
2023-10-18 02:57:47 +02:00
/**
* Retrieves element from media backend by hash
* @param hash The hash of the element in SHA-256 hex format
* @param extension The extension of the file
2023-10-18 02:57:47 +02:00
* @returns The file as a File object
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
async getMediaByHash(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
hash: string
2023-10-18 02:57:47 +02:00
): Promise<File | null> {
return new File([], "test");
}
}
/**
* S3 Backend, stores files in S3
*/
export class S3Backend extends MediaBackend {
client: S3Client;
config: ConfigType;
2023-10-18 02:57:47 +02:00
constructor(config: ConfigType) {
super("s3");
this.config = config;
2023-10-18 02:57:47 +02:00
this.client = new S3Client({
endpoint: this.config.s3.endpoint,
region: this.config.s3.region || "auto",
2023-10-18 02:57:47 +02:00
credentials: {
accessKeyId: this.config.s3.access_key,
secretAccessKey: this.config.s3.secret_access_key,
2023-10-18 02:57:47 +02:00
},
});
}
async addMedia(media: File): Promise<string> {
if (this.config.media.conversion.convert_images) {
media = await this.convertMedia(media, this.config);
}
2023-10-18 02:57:47 +02:00
const hash = await super.addMedia(media);
if (!hash) {
throw new Error("Failed to hash file");
}
// Check if file is already present
const existingFile = await this.getMediaByHash(hash);
2023-10-18 02:57:47 +02:00
if (existingFile) {
// File already exists, so return the hash without uploading it
return hash;
}
const command = new PutObjectCommand({
Bucket: this.config.s3.bucket_name,
2023-10-18 02:57:47 +02:00
Key: hash,
Body: Buffer.from(await media.arrayBuffer()),
ContentType: media.type,
ContentLength: media.size,
Metadata: {
"x-amz-meta-original-name": media.name,
},
});
const response = await this.client.send(command);
if (response.$metadata.httpStatusCode !== 200) {
throw new Error("Failed to upload file");
}
return hash;
}
async getMediaByHash(hash: string): Promise<File | null> {
2023-10-18 02:57:47 +02:00
const command = new GetObjectCommand({
Bucket: this.config.s3.bucket_name,
2023-10-18 02:57:47 +02:00
Key: hash,
});
let response: GetObjectCommandOutput;
try {
response = await this.client.send(command);
} catch {
return null;
}
if (response.$metadata.httpStatusCode !== 200) {
throw new Error("Failed to get file");
}
const body = await response.Body?.transformToByteArray();
if (!body) {
throw new Error("Failed to get file");
}
return new File([body], hash, {
2023-10-18 02:57:47 +02:00
type: response.ContentType,
});
}
}
/**
* Local backend, stores files on filesystem
*/
export class LocalBackend extends MediaBackend {
config: ConfigType;
constructor(config: ConfigType) {
2023-10-18 02:57:47 +02:00
super("local");
this.config = config;
2023-10-18 02:57:47 +02:00
}
async addMedia(media: File): Promise<string> {
if (this.config.media.conversion.convert_images) {
media = await this.convertMedia(media, this.config);
}
2023-10-18 02:57:47 +02:00
const hash = await super.addMedia(media);
if (!(await exists(`${process.cwd()}/uploads`))) {
await mkdir(`${process.cwd()}/uploads`);
}
2023-10-18 02:57:47 +02:00
await Bun.write(Bun.file(`${process.cwd()}/uploads/${hash}`), media);
return hash;
}
async getMediaByHash(hash: string): Promise<File | null> {
2023-10-18 02:57:47 +02:00
const file = Bun.file(`${process.cwd()}/uploads/${hash}`);
if (!(await file.exists())) {
return null;
}
return new File([await file.arrayBuffer()], `${hash}`, {
2023-10-18 02:57:47 +02:00
type: file.type,
});
}
}
export const uploadFile = (file: File, config: ConfigType) => {
const backend = config.media.backend;
if (backend === "local") {
return new LocalBackend(config).addMedia(file);
} else if (backend === "s3") {
return new S3Backend(config).addMedia(file);
}
};
export const getFile = (
hash: string,
extension: string,
config: ConfigType
) => {
const backend = config.media.backend;
if (backend === "local") {
return new LocalBackend(config).getMediaByHash(hash);
} else if (backend === "s3") {
return new S3Backend(config).getMediaByHash(hash);
}
return null;
};