327 lines
		
	
	
		
			8.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			327 lines
		
	
	
		
			8.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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<boolean> {
 | |
|   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;
 | |
|   }
 | |
| }
 |