import { HandlerContext, Handlers } from "$fresh/server.ts"; import { ImageMagick, initialize, MagickFormat, MagickGeometry, } from "https://deno.land/x/imagemagick_deno@0.0.25/mod.ts"; import { parseMediaType } from "https://deno.land/std@0.175.0/media_types/parse_media_type.ts"; import * as cache from "@lib/cache/image.ts"; import { SILVERBULLET_SERVER } from "@lib/env.ts"; import { PromiseQueue } from "@lib/promise.ts"; import { BadRequestError } from "@lib/errors.ts"; import { createLogger } from "@lib/log.ts"; await initialize(); type ImageParams = { width: number; height: number; }; const log = createLogger("api/image"); async function getRemoteImage(image: string) { log.debug("[api/image] fetching", { image }); const sourceRes = await fetch(image); if (!sourceRes.ok) { return "Error retrieving image from URL."; } const mediaType = parseMediaType(sourceRes.headers.get("Content-Type")!)[0]; if (mediaType.split("/")[0] !== "image") { return "URL is not image type."; } return { buffer: new Uint8Array(await sourceRes.arrayBuffer()), mediaType, }; } function getWidthHeight( current: { width: number; height: number }, final: { width: number; height: number }, ) { const ratio = (current.width / final.width) > (current.height / final.height) ? (current.height / final.height) : (current.width / final.width); return new MagickGeometry( Math.floor(current.width / ratio), Math.floor(current.height / ratio), ); } function modifyImage( imageBuffer: Uint8Array, params: { width: number; mediaType: string; height: number; mode: "resize" | "crop"; }, ) { return new Promise((resolve) => { let format = MagickFormat.Jpeg; if (params.mediaType === "image/webp") { format = MagickFormat.Webp; } ImageMagick.read(imageBuffer, format, (image) => { const sizingData = getWidthHeight(image, params); if (params.mode === "resize") { image.resize(sizingData); } else { image.crop(sizingData); } image.write((data) => resolve(data)); }); }); } function parseParams(reqUrl: URL) { const image = reqUrl.searchParams.get("image")?.replace(/^\//, ""); if (image == null) { return "Missing 'image' query parameter."; } const height = Number(reqUrl.searchParams.get("height")) || 0; const width = Number(reqUrl.searchParams.get("width")) || 0; if (height === 0 && width === 0) { //return "Missing non-zero 'height' or 'width' query parameter."; } if (height < 0 || width < 0) { return "Negative height or width is not supported."; } const maxDimension = 2048; if (height > maxDimension || width > maxDimension) { return `Width and height cannot exceed ${maxDimension}.`; } return { image, height, width, }; } const queue = new PromiseQueue(); async function processImage(imageUrl: string, params: ImageParams) { const remoteImage = await getRemoteImage(imageUrl); if (typeof remoteImage === "string") { log.warn("error fetching ", { remoteImage }); throw new BadRequestError(); } const modifiedImage = await modifyImage(remoteImage.buffer, { ...params, mediaType: remoteImage.mediaType, mode: "resize", }); log.debug("resized image", { imageUrl, length: modifiedImage.length, }); return [modifiedImage, remoteImage.mediaType] as const; } const GET = async ( _req: Request, _ctx: HandlerContext, ): Promise => { const url = new URL(_req.url); const params = parseParams(url); if (typeof params === "string") { return new Response(params, { status: 400 }); } const imageUrl = SILVERBULLET_SERVER + "/" + params.image; const cachedResponse = await cache.getImage({ url: imageUrl, width: params.width, height: params.height, }); if (cachedResponse) { log.debug("cached", { imageUrl }); return new Response(cachedResponse.buffer.slice(), { headers: { "Content-Type": cachedResponse.mediaType, }, }); } else { log.debug("no image in cache"); } const [resizedImage, mediaType] = await queue.enqueue(() => processImage(imageUrl, params) ); cache.setImage(resizedImage, { url: imageUrl, width: params.width, height: params.height, mediaType: mediaType, }); log.debug("not-cached", { imageUrl, resizedImage }); return new Response(new Uint8Array(resizedImage), { headers: { "Content-Type": mediaType, }, }); }; export const handler: Handlers = { GET, };