2023-11-23 05:10:37 +01:00
|
|
|
import type { GetObjectCommandOutput } from "@aws-sdk/client-s3";
|
2023-10-18 02:57:47 +02:00
|
|
|
import {
|
|
|
|
|
GetObjectCommand,
|
|
|
|
|
PutObjectCommand,
|
|
|
|
|
S3Client,
|
|
|
|
|
} from "@aws-sdk/client-s3";
|
2023-11-23 05:10:37 +01:00
|
|
|
import type { ConfigType } from "@config";
|
2023-10-19 21:53:59 +02:00
|
|
|
import sharp from "sharp";
|
2023-11-19 21:36:54 +01:00
|
|
|
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
|
2023-10-19 21:53:59 +02:00
|
|
|
* @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");
|
|
|
|
|
|
2023-10-19 21:53:59 +02:00
|
|
|
return `${hash}.${media.name.split(".").pop()}`;
|
2023-10-18 02:57:47 +02:00
|
|
|
}
|
2023-10-19 21:53:59 +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
|
2023-10-19 21:53:59 +02:00
|
|
|
* @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
|
2023-10-19 21:53:59 +02:00
|
|
|
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;
|
2023-10-19 21:53:59 +02:00
|
|
|
config: ConfigType;
|
2023-10-18 02:57:47 +02:00
|
|
|
|
|
|
|
|
constructor(config: ConfigType) {
|
|
|
|
|
super("s3");
|
|
|
|
|
|
2023-10-19 21:53:59 +02:00
|
|
|
this.config = config;
|
2023-10-18 02:57:47 +02:00
|
|
|
|
|
|
|
|
this.client = new S3Client({
|
2023-10-19 21:53:59 +02:00
|
|
|
endpoint: this.config.s3.endpoint,
|
|
|
|
|
region: this.config.s3.region || "auto",
|
2023-10-18 02:57:47 +02:00
|
|
|
credentials: {
|
2023-10-19 21:53:59 +02:00
|
|
|
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> {
|
2023-10-19 21:53:59 +02:00
|
|
|
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
|
2023-10-19 21:53:59 +02:00
|
|
|
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({
|
2023-10-19 21:53:59 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-19 21:53:59 +02:00
|
|
|
async getMediaByHash(hash: string): Promise<File | null> {
|
2023-10-18 02:57:47 +02:00
|
|
|
const command = new GetObjectCommand({
|
2023-10-19 21:53:59 +02:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-19 21:53:59 +02:00
|
|
|
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 {
|
2023-10-19 21:53:59 +02:00
|
|
|
config: ConfigType;
|
|
|
|
|
|
|
|
|
|
constructor(config: ConfigType) {
|
2023-10-18 02:57:47 +02:00
|
|
|
super("local");
|
2023-10-19 21:53:59 +02:00
|
|
|
|
|
|
|
|
this.config = config;
|
2023-10-18 02:57:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async addMedia(media: File): Promise<string> {
|
2023-10-19 21:53:59 +02:00
|
|
|
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);
|
|
|
|
|
|
2023-11-19 21:36:54 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-19 21:53:59 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-19 21:53:59 +02:00
|
|
|
return new File([await file.arrayBuffer()], `${hash}`, {
|
2023-10-18 02:57:47 +02:00
|
|
|
type: file.type,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-10-19 21:53:59 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|