import * as fs from "node:fs"; import sharp from "sharp"; import { convertToWebp } from "../../services/drive/image-processor.js"; import { createTemp } from "../../misc/create-temp.js"; import { downloadUrl } from "../../misc/download-url.js"; import { detectType } from "../../misc/get-file-info.js"; import { StatusError } from "../../misc/fetch.js"; import { FILE_TYPE_BROWSERSAFE, MINUTE } from "../../const.js"; import { serverLogger } from "../index.js"; import { isMimeImage } from "../../misc/is-mime-image.js"; import { getIpHash } from "../../misc/get-ip-hash.js"; import { limiter } from "../api/limiter.js"; import { isIgnorableConnectionError } from "../is-ignorable-connection-error.js"; export async function proxyMedia(ctx) { const url = "url" in ctx.query ? ctx.query.url : `https://${ctx.params.url}`; if (typeof url !== "string") { ctx.status = 400; return; } // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. let limitActor; limitActor = getIpHash(ctx.ip); const parsedUrl = new URL(url); const limit = { key: `media-proxy:${parsedUrl.host}:${parsedUrl.pathname}`, duration: MINUTE * 10, max: 10 }; // Rate limit await limiter(limit, limitActor).catch((e)=>{ const remainingTime = e.remainingTime ? `Please try again in ${e.remainingTime}.` : "Please try again later."; ctx.status = 429; ctx.body = "Rate limit exceeded. " + remainingTime; }); if (ctx.status == 429) return; // Create temp file const [path, cleanup] = await createTemp(); try { await downloadUrl(url, path); const { mime, ext } = await detectType(path); const isConvertibleImage = isMimeImage(mime, "sharp-convertible-image"); let image; if ("glyph" in ctx.query) { if (mime !== "image/svg+xml") { throw new StatusError("Unexpected mime", 404); } ctx.set("Content-Type", "text/plain; charset=utf-8"); ctx.set("Cache-Control", "max-age=31536000, immutable"); ctx.body = fs.readFileSync(path, "utf8"); return; } else if ("static" in ctx.query && isConvertibleImage) { image = await convertToWebp(path, 996, 560); } else if ("preview" in ctx.query && isConvertibleImage) { image = await convertToWebp(path, 400, 400); } else if ("badge" in ctx.query) { if (!isConvertibleImage) { // 画像でないなら404でお茶を濁す throw new StatusError("Unexpected mime", 404); } const mask = sharp(path).resize(96, 96, { fit: "inside", withoutEnlargement: false }).greyscale().normalise().linear(1.75, -128 * 1.75 + 128) // 1.75x contrast .flatten({ background: "#000" }).toColorspace("b-w"); const stats = await mask.clone().stats(); if (stats.entropy < 0.1) { // エントロピーがあまりない場合は404にする throw new StatusError("Skip to provide badge", 404); } const data = sharp({ create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).pipelineColorspace("b-w").boolean(await mask.png().toBuffer(), "eor"); image = { data: await data.png().toBuffer(), ext: "png", type: "image/png" }; } else if (mime === "image/svg+xml") { image = await convertToWebp(path, 2048, 2048, 1); } else if (!(mime.startsWith("image/") && FILE_TYPE_BROWSERSAFE.includes(mime))) { throw new StatusError("Rejected type", 403, "Rejected type"); } else { image = { data: fs.readFileSync(path), ext, type: mime }; } ctx.set("Content-Type", image.type); ctx.set("Cache-Control", "max-age=31536000, immutable"); ctx.body = image.data; } catch (e) { if (isIgnorableConnectionError(e)) return; serverLogger.error(`${e}`); if (e instanceof StatusError && (e.statusCode === 302 || e.isClientError)) { ctx.status = e.statusCode; } else { ctx.status = 500; } } finally{ cleanup(); } }