diff --git a/api/src/controllers/images.ts b/api/src/controllers/images.ts index 170f4e2b..df04e794 100644 --- a/api/src/controllers/images.ts +++ b/api/src/controllers/images.ts @@ -1,5 +1,5 @@ -import { stat } from "node:fs/promises"; -import type { BunFile } from "bun"; +import type { Stats } from "node:fs"; +import type { BunFile, S3File, S3Stats } from "bun"; import { type SQL, and, eq, sql } from "drizzle-orm"; import Elysia, { type Context, t } from "elysia"; import { prefix } from "~/base"; @@ -15,6 +15,7 @@ import { sqlarr } from "~/db/utils"; import { KError } from "~/models/error"; import { bubble } from "~/models/examples"; import { AcceptLanguage, isUuid, processLanguages } from "~/models/utils"; +import { getFile } from "~/utils"; import { imageDir } from "./seed/images"; function getRedirectToImageHandler({ @@ -98,7 +99,7 @@ export const imagesH = new Elysia({ tags: ["images"] }) "/images/:id", async ({ params: { id }, query: { quality }, headers: reqHeaders }) => { const path = `${imageDir}/${id}.${quality}.jpg`; - const file = Bun.file(path); + const file = getFile(path); const etag = await generateETag(file); if (await isCached(reqHeaders, etag, path)) @@ -366,8 +367,13 @@ export async function isCached( if (headers["if-modified-since"]) { const ifModifiedSince = headers["if-modified-since"]; let lastModified: Date | undefined; + const stat = await getFile(filePath).stat(); try { - lastModified = (await stat(filePath)).mtime; + if ((stat as S3Stats).lastModified) { + lastModified = (stat as S3Stats).lastModified; + } else if ((stat as Stats).mtime) { + lastModified = (stat as Stats).mtime; + } } catch { /* empty */ } @@ -382,7 +388,7 @@ export async function isCached( return false; } -export async function generateETag(file: BunFile) { +export async function generateETag(file: BunFile | S3File) { const hash = new Bun.CryptoHasher("md5"); hash.update(await file.arrayBuffer()); diff --git a/api/src/controllers/seed/images.ts b/api/src/controllers/seed/images.ts index 0896836b..d61ee9f1 100644 --- a/api/src/controllers/seed/images.ts +++ b/api/src/controllers/seed/images.ts @@ -1,4 +1,3 @@ -import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { encode } from "blurhash"; import { type SQL, and, eq, is, lt, sql } from "drizzle-orm"; @@ -9,10 +8,9 @@ import sharp from "sharp"; import { type Transaction, db } from "~/db"; import { mqueue } from "~/db/schema/mqueue"; import type { Image } from "~/models/utils"; +import { getFile } from "~/utils"; export const imageDir = process.env.IMAGES_PATH ?? "./images"; -await mkdir(imageDir, { recursive: true }); - export const defaultBlurhash = "000000"; type ImageTask = { @@ -164,7 +162,9 @@ async function downloadImage(id: string, url: string): Promise { await Promise.all( Object.entries(resolutions).map(async ([resolution, dimensions]) => { const buffer = await image.clone().resize(dimensions.width).toBuffer(); - await writeFile(path.join(imageDir, `${id}.${resolution}.jpg`), buffer); + const file = getFile(path.join(imageDir, `${id}.${resolution}.jpg`)); + + await Bun.write(file, buffer, { mode: 0o660 }); }), ); diff --git a/api/src/utils.ts b/api/src/utils.ts index 72d76e2e..5c251e67 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,3 +1,5 @@ +import type { BunFile, S3File } from "bun"; + // remove indent in multi-line comments export const comment = (str: TemplateStringsArray, ...values: any[]) => str @@ -14,3 +16,15 @@ export function getYear(date: string) { export type Prettify = { [K in keyof T]: Prettify; } & {}; + +// Returns either a filesystem-backed file, or a S3-backed file, +// depending on whether or not S3 environment variables are set. +export function getFile(path: string): BunFile | S3File { + if ("S3_BUCKET" in process.env || "AWS_BUCKET" in process.env) { + // This will use a S3 client configured via environment variables. + // See https://bun.sh/docs/api/s3#credentials for more details. + return Bun.s3.file(path); + } + + return Bun.file(path); +}