import { rgbToHex } from "@lib/string.ts"; import { createLogger } from "@lib/log/index.ts"; import { generateThumbhash } from "@lib/thumbhash.ts"; import { parseMediaType } from "https://deno.land/std@0.224.0/media_types/parse_media_type.ts"; import path from "node:path"; import { ensureDir } from "https://deno.land/std@0.216.0/fs/mod.ts"; import { DATA_DIR } from "@lib/env.ts"; import { db } from "@lib/db/sqlite.ts"; import { imageTable } from "@lib/db/schema.ts"; import { eq } from "drizzle-orm"; import sharp from "npm:sharp@next"; const log = createLogger("cache/image"); const imageDir = path.join(DATA_DIR, "images"); await ensureDir(imageDir); async function getRemoteImage(imageUrl: string) { try { const sourceRes = await fetch(imageUrl); if (!sourceRes.ok) { throw new Error( `Failed to retrieve image from URL: ${imageUrl}. Status: ${sourceRes.status}`, ); } const contentType = sourceRes.headers.get("Content-Type"); if (!contentType) { throw new Error("No Content-Type header in response"); } const mediaType = parseMediaType(contentType)[0]; if (mediaType.split("/")[0] !== "image") { throw new Error("URL does not return an image type"); } log.debug("Fetching image", { imageUrl, mediaType }); const buffer = await sourceRes.arrayBuffer(); if (buffer.byteLength === 0) { throw new Error("Received empty image buffer"); } return { buffer, mediaType, }; } catch (error) { throw error; } } /** * Calculates dimensions maintaining aspect ratio */ function calculateDimensions( current: { width: number; height: number }, target: { width?: number; height?: number }, ) { if (!target.width && !target.height) { return current; } const ratio = current.width / current.height; if (target.width && target.height) { // Both dimensions specified - maintain aspect ratio using the most constraining dimension const widthRatio = target.width / current.width; const heightRatio = target.height / current.height; const scale = Math.min(widthRatio, heightRatio); return { width: Math.round(current.width * scale), height: Math.round(current.height * scale), }; } else if (target.width) { // Only width specified return { width: target.width, height: Math.round(target.width / ratio), }; } else if (target.height) { // Only height specified return { width: Math.round(target.height * ratio), height: target.height, }; } return current; } async function getLocalImagePath( url: string, { width, height }: { width?: number; height?: number } = {}, ) { const { hostname, pathname } = new URL(url); let imagePath = path.join( imageDir, hostname, pathname.split("/").filter((s) => s.length).join("-"), ); await ensureDir(imagePath); if (width || height) { imagePath = path.join(imagePath, `${width ?? "-"}x${height ?? "-"}`); } else { imagePath = path.join(imagePath, "original"); } return imagePath; } /** * Retrieves a cached image from local storage */ async function getLocalImage( url: string, { width, height }: { width?: number; height?: number } = {}, ) { const imagePath = await getLocalImagePath(url, { width, height }); try { const fileInfo = await Deno.stat(imagePath); if (fileInfo?.isFile) { return Deno.readFile(imagePath); } } catch (_) { // File not found - normal case } } /** * Stores an image in local cache */ async function storeLocalImage( url: string, content: ArrayBuffer, { width, height }: { width?: number; height?: number } = {}, ) { const isValid = await verifyImage(new Uint8Array(content)); if (!isValid) { throw new Error("Invalid image content detected during storage"); } const imagePath = await getLocalImagePath(url, { width, height }); await Deno.writeFile(imagePath, new Uint8Array(content)); } /** * Resizes an image using Sharp with proper error handling */ async function resizeImage( imageBuffer: Uint8Array, params: { width?: number; height?: number; mediaType: string; }, ) { try { log.debug("Resizing image", { params }); let sharpInstance = sharp(imageBuffer); // Get original dimensions const metadata = await sharpInstance.metadata(); if (!metadata.width || !metadata.height) { throw new Error("Could not determine image dimensions"); } // Calculate new dimensions const newDimensions = calculateDimensions( { width: metadata.width, height: metadata.height }, params, ); switch (params.mediaType) { case "image/webp": sharpInstance = sharpInstance.webp({ quality: 85 }); break; case "image/png": sharpInstance = sharpInstance.png({ quality: 85 }); break; case "image/jpeg": default: sharpInstance = sharpInstance.jpeg({ quality: 85 }); break; } // Perform resize with proper options const resized = await sharpInstance .resize({ width: newDimensions.width, height: newDimensions.height, fit: "inside", withoutEnlargement: true, }) .toBuffer(); return new Uint8Array(resized); } catch (error) { log.error("Error during image resize:", error); throw error; } } /** * Creates a thumbhash for image preview */ async function createThumbhash( image: Uint8Array, ): Promise<{ hash: string; average: string }> { try { const resizedImage = await sharp(image) .resize(100, 100, { fit: "cover" }) // Keep aspect ratio within bounds .toFormat("png") .ensureAlpha() .raw() .toBuffer(); const [hash, average] = generateThumbhash(resizedImage, 100, 100); return { hash: btoa(String.fromCharCode(...hash)), average: rgbToHex(average.r, average.g, average.b), }; } catch (err) { throw new Error(`Failed to create thumbhash: ${err}`); } } /** * Verifies that an image buffer contains valid image data */ async function verifyImage(imageBuffer: Uint8Array): Promise { try { const metadata = await sharp(imageBuffer).metadata(); return !!(metadata.width && metadata.height && metadata.format); } catch (error) { log.error("Image verification failed:", error); return false; } } /** * Gets image content with proper caching and resizing */ export async function getImageContent( url: string, { width, height }: { width?: number; height?: number } = {}, ): Promise<{ content: ArrayBuffer; mimeType: string }> { log.debug("Getting image content", { url, width, height }); // Check if we have the image metadata in database const image = await getImage(url); // Try to get cached resized version const cachedImage = await getLocalImage(url, { width, height }); if (cachedImage) { return { content: cachedImage, mimeType: image.mime }; } // Try to get cached original version let originalImage = await getLocalImage(url); // Fetch and cache original if needed if (!originalImage) { const fetchedImage = await getRemoteImage(url); await storeLocalImage(url, fetchedImage.buffer); originalImage = new Uint8Array(fetchedImage.buffer); } // Resize image const resizedImage = await resizeImage(originalImage, { width, height, mediaType: image.mime, }); // Cache resized version await storeLocalImage(url, resizedImage, { width, height }); return { content: resizedImage, mimeType: image.mime }; } /** * Gets or creates image metadata in database */ export async function getImage(url: string) { log.debug("Getting image metadata", { url }); try { // Check database first const image = await db.select().from(imageTable) .where(eq(imageTable.url, url)) .limit(1) .then((images) => images[0]); if (image) { return image; } // Fetch and process new image const imageContent = await getRemoteImage(url); await storeLocalImage(url, imageContent.buffer); // Generate thumbhash const thumbhash = await createThumbhash( new Uint8Array(imageContent.buffer), ); // Store in database const [newImage] = await db.insert(imageTable).values({ url: url, blurhash: thumbhash.hash, average: thumbhash.average, mime: imageContent.mediaType, }).returning(); return newImage; } catch (error) { log.error("Error in getImage:", error); throw error; } }