2023-08-01 17:50:00 +02:00
|
|
|
import { HandlerContext, Handlers } from "$fresh/server.ts";
|
2023-07-26 15:48:03 +02:00
|
|
|
import {
|
|
|
|
ImageMagick,
|
2023-08-02 00:23:51 +02:00
|
|
|
initialize,
|
2023-08-02 02:51:20 +02:00
|
|
|
MagickFormat,
|
2023-07-26 15:48:03 +02:00
|
|
|
MagickGeometry,
|
2023-08-02 00:23:51 +02:00
|
|
|
} from "https://deno.land/x/imagemagick_deno@0.0.25/mod.ts";
|
2023-08-02 02:51:20 +02:00
|
|
|
|
2023-07-26 15:48:03 +02:00
|
|
|
import { parseMediaType } from "https://deno.land/std@0.175.0/media_types/parse_media_type.ts";
|
2023-07-30 18:27:45 +02:00
|
|
|
import * as cache from "@lib/cache/image.ts";
|
2023-08-01 17:50:00 +02:00
|
|
|
import { SILVERBULLET_SERVER } from "@lib/env.ts";
|
2023-08-02 02:24:08 +02:00
|
|
|
import { PromiseQueue } from "@lib/promise.ts";
|
|
|
|
import { BadRequestError } from "@lib/errors.ts";
|
2023-07-26 13:47:01 +02:00
|
|
|
|
2023-08-02 00:23:51 +02:00
|
|
|
await initialize();
|
2023-07-26 15:48:03 +02:00
|
|
|
|
2023-08-02 02:24:08 +02:00
|
|
|
type ImageParams = {
|
|
|
|
width: number;
|
|
|
|
height: number;
|
|
|
|
};
|
|
|
|
|
2023-07-26 15:48:03 +02:00
|
|
|
async function getRemoteImage(image: string) {
|
2023-07-30 19:40:39 +02:00
|
|
|
console.log("[api/image] fetching", { image });
|
2023-07-26 15:48:03 +02:00
|
|
|
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(
|
2023-08-02 02:24:08 +02:00
|
|
|
Math.floor(current.width / ratio),
|
|
|
|
Math.floor(current.height / ratio),
|
2023-07-26 15:48:03 +02:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function modifyImage(
|
|
|
|
imageBuffer: Uint8Array,
|
2023-08-02 02:51:20 +02:00
|
|
|
params: {
|
|
|
|
width: number;
|
|
|
|
mediaType: string;
|
|
|
|
height: number;
|
|
|
|
mode: "resize" | "crop";
|
|
|
|
},
|
2023-07-26 15:48:03 +02:00
|
|
|
) {
|
|
|
|
return new Promise<Uint8Array>((resolve) => {
|
2023-08-02 02:51:20 +02:00
|
|
|
let format = MagickFormat.Jpeg;
|
|
|
|
|
|
|
|
if (params.mediaType === "image/webp") {
|
|
|
|
format = MagickFormat.Webp;
|
|
|
|
}
|
|
|
|
|
|
|
|
ImageMagick.read(imageBuffer, format, (image) => {
|
2023-07-26 15:48:03 +02:00
|
|
|
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) {
|
2023-07-30 18:27:45 +02:00
|
|
|
const image = reqUrl.searchParams.get("image")?.replace(/^\//, "");
|
|
|
|
if (image == null) {
|
|
|
|
return "Missing 'image' query parameter.";
|
|
|
|
}
|
|
|
|
|
2023-07-26 15:48:03 +02:00
|
|
|
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.";
|
2023-07-26 13:47:01 +02:00
|
|
|
}
|
2023-07-26 15:48:03 +02:00
|
|
|
const maxDimension = 2048;
|
|
|
|
if (height > maxDimension || width > maxDimension) {
|
|
|
|
return `Width and height cannot exceed ${maxDimension}.`;
|
|
|
|
}
|
|
|
|
return {
|
2023-07-30 18:27:45 +02:00
|
|
|
image,
|
2023-07-26 15:48:03 +02:00
|
|
|
height,
|
|
|
|
width,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-08-02 02:24:08 +02:00
|
|
|
const queue = new PromiseQueue();
|
|
|
|
|
|
|
|
async function processImage(imageUrl: string, params: ImageParams) {
|
|
|
|
const remoteImage = await getRemoteImage(imageUrl);
|
|
|
|
if (typeof remoteImage === "string") {
|
|
|
|
console.log("[api/image] ERROR " + remoteImage);
|
|
|
|
throw new BadRequestError();
|
|
|
|
}
|
|
|
|
|
|
|
|
const modifiedImage = await modifyImage(remoteImage.buffer, {
|
|
|
|
...params,
|
2023-08-02 02:51:20 +02:00
|
|
|
mediaType: remoteImage.mediaType,
|
2023-08-02 02:24:08 +02:00
|
|
|
mode: "resize",
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log("[api/image] resized image", {
|
|
|
|
imageUrl,
|
|
|
|
length: modifiedImage.length,
|
|
|
|
});
|
|
|
|
|
|
|
|
return [modifiedImage, remoteImage.mediaType] as const;
|
|
|
|
}
|
|
|
|
|
2023-08-01 17:50:00 +02:00
|
|
|
const GET = async (
|
2023-07-26 13:47:01 +02:00
|
|
|
_req: Request,
|
|
|
|
_ctx: HandlerContext,
|
|
|
|
): Promise<Response> => {
|
2023-07-26 15:48:03 +02:00
|
|
|
const url = new URL(_req.url);
|
|
|
|
|
|
|
|
const params = parseParams(url);
|
|
|
|
if (typeof params === "string") {
|
|
|
|
return new Response(params, { status: 400 });
|
|
|
|
}
|
|
|
|
|
2023-08-01 17:50:00 +02:00
|
|
|
const imageUrl = SILVERBULLET_SERVER + "/" + params.image;
|
2023-07-30 18:27:45 +02:00
|
|
|
|
|
|
|
const cachedResponse = await cache.getImage({
|
|
|
|
url: imageUrl,
|
|
|
|
width: params.width,
|
|
|
|
height: params.height,
|
|
|
|
});
|
2023-07-26 16:19:28 +02:00
|
|
|
if (cachedResponse) {
|
2023-08-02 02:51:20 +02:00
|
|
|
console.log("[api/image] cached: " + imageUrl);
|
2023-07-30 18:27:45 +02:00
|
|
|
return new Response(cachedResponse.buffer.slice(), {
|
|
|
|
headers: {
|
|
|
|
"Content-Type": cachedResponse.mediaType,
|
|
|
|
},
|
|
|
|
});
|
2023-07-30 18:32:21 +02:00
|
|
|
} else {
|
|
|
|
console.log("[api/image] no image in cache");
|
2023-07-26 15:48:03 +02:00
|
|
|
}
|
|
|
|
|
2023-08-02 02:24:08 +02:00
|
|
|
const [resizedImage, mediaType] = await queue.enqueue(() =>
|
|
|
|
processImage(imageUrl, params)
|
|
|
|
);
|
2023-08-01 18:35:35 +02:00
|
|
|
|
2023-08-02 13:11:17 +02:00
|
|
|
cache.setImage(resizedImage, {
|
2023-08-02 02:51:20 +02:00
|
|
|
url: imageUrl,
|
|
|
|
width: params.width,
|
|
|
|
height: params.height,
|
|
|
|
mediaType: mediaType,
|
|
|
|
});
|
|
|
|
|
|
|
|
console.log("[api/image] not-cached: " + imageUrl);
|
2023-08-02 13:11:17 +02:00
|
|
|
console.log({ imageUrl, resizedImage });
|
2023-08-02 02:51:20 +02:00
|
|
|
|
2023-08-02 13:11:17 +02:00
|
|
|
return new Response(new Uint8Array(resizedImage), {
|
2023-07-30 18:27:45 +02:00
|
|
|
headers: {
|
2023-08-02 02:24:08 +02:00
|
|
|
"Content-Type": mediaType,
|
2023-07-30 18:27:45 +02:00
|
|
|
},
|
|
|
|
});
|
2023-07-26 13:47:01 +02:00
|
|
|
};
|
2023-08-01 17:50:00 +02:00
|
|
|
|
|
|
|
export const handler: Handlers = {
|
|
|
|
GET,
|
|
|
|
};
|