mrmBot-Matrix/utils/image.js
adroitwhiz 3de4858b5a
Clean up image API code paths (#49)
* Document image.js a bit

* Close image.js sockets in all code paths

I'm not sure whether sockets get GC'd when the function returns

* Remove getFormat

It was only called from one place, and the object property names were
quite confusing

* Clean up image.js conditional a bit

I had to write out an entire truth table for this and work it all out
Thinking hard

* Move actual ImageMagick calling into separate file

This gets rid of the weird, brain-melting ouroboros of code that
recurses across threads and processes.

* Reduce amount of getType wrangling

This amounted to an awful lot of dead conditionals after the image
commands were all modified to pass in image types anyway. This has also
led to two different implementations diverging, which causes bugs like
GIF commands applied to non-GIFs erroring instead of providing a
user-friendly message.

* Unify image-runner return type, clarify image type

This allows us to remove the fromAPI parameter from image-runner, and
helps greatly clarify the behavior around image types.

* Deduplicate GIF code, fix "not a GIF" handling

The special "nogif" value is now stored as the image type instead of its
value, as the value must always be a Buffer now--no loosely-typed
shenanigans.
2021-01-09 19:50:29 -06:00

181 lines
5.4 KiB
JavaScript

const magick = require("../build/Release/image.node");
const { Worker } = require("worker_threads");
const fetch = require("node-fetch");
const AbortController = require("abort-controller");
const net = require("net");
const dgram = require("dgram");
const fileType = require("file-type");
const servers = require("../servers.json").image;
const path = require("path");
const formats = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const chooseServer = async (ideal) => {
if (ideal.length === 0) throw "No available servers";
const sorted = ideal.sort((a, b) => {
return b.load - a.load;
});
return sorted[0];
};
const getIdeal = () => {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket("udp4");
let serversLeft = servers.length;
const idealServers = [];
const timeout = setTimeout(() => {
socket.close(async () => {
try {
const server = await chooseServer(idealServers);
resolve(server);
} catch (e) {
reject(e);
}
});
}, 5000);
socket.on("message", async (msg, rinfo) => {
const opcode = msg.readUint8(0);
const res = parseInt(msg.slice(1, msg.length).toString());
if (opcode === 0x3) {
serversLeft--;
idealServers.push({
addr: rinfo.address,
load: res
});
if (!serversLeft) {
clearTimeout(timeout);
socket.close(async () => {
try {
const server = await chooseServer(idealServers);
resolve(server);
} catch (e) {
reject(e);
}
});
}
}
});
for (const server of servers) {
socket.send(Buffer.from([0x2]), 8080, server, (err) => {
if (err) reject(err);
});
}
});
};
exports.check = (cmd) => {
return magick[cmd] ? true : false;
};
exports.getType = async (image) => {
if (!image.startsWith("http")) {
const imageType = await fileType.fromFile(image);
if (imageType && formats.includes(imageType.mime)) {
return imageType.mime;
}
return undefined;
}
let type;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 25000);
try {
const imageRequest = await fetch(image, { signal: controller.signal, headers: {
"Range": "bytes=0-1023"
}});
clearTimeout(timeout);
const imageBuffer = await imageRequest.buffer();
const imageType = await fileType.fromBuffer(imageBuffer);
if (imageType && formats.includes(imageType.mime)) {
type = imageType.mime;
}
} catch (error) {
if (error.name === "AbortError") {
throw Error("Timed out");
} else {
throw error;
}
} finally {
clearTimeout(timeout);
}
return type;
};
exports.run = object => {
return new Promise(async (resolve, reject) => {
if (process.env.API === "true") {
// Connect to best image server
const currentServer = await getIdeal();
const socket = dgram.createSocket("udp4");
const data = Buffer.concat([Buffer.from([0x1 /* queue job */]), Buffer.from(JSON.stringify(object))]);
const timeout = setTimeout(() => {
socket.close();
reject("UDP timed out");
}, 25000);
let jobID;
socket.on("message", (msg) => {
const opcode = msg.readUint8(0);
const req = msg.slice(37, msg.length);
const uuid = msg.slice(1, 36).toString();
if (opcode === 0x0) { // Job queued
clearTimeout(timeout);
jobID = uuid;
} else if (opcode === 0x1) { // Job completed successfully
// the image API sends all job responses over the same socket; make sure this is ours
if (jobID === uuid) {
const client = net.createConnection(req.toString(), currentServer.addr);
const array = [];
client.on("data", (rawData) => {
array.push(rawData);
});
client.once("end", () => {
const data = Buffer.concat(array);
// The response data is given as the file extension/ImageMagick type of the image (e.g. "png"), followed
// by a newline, followed by the image data.
const delimIndex = data.indexOf("\n");
socket.close();
if (delimIndex === -1) reject("Could not parse response");
const payload = {
// Take just the image data
buffer: data.slice(delimIndex + 1),
type: data.slice(0, delimIndex).toString()
};
resolve(payload);
});
client.on("error", (err) => {
socket.close();
reject(err);
});
}
} else if (opcode === 0x2) { // Job errored
if (jobID === uuid) {
socket.close();
reject(req);
}
}
});
socket.send(data, 8080, currentServer.addr, (err) => {
if (err) {
socket.close();
reject(err);
}
});
} else {
// Called from command (not using image API)
const worker = new Worker(path.join(__dirname, "image-runner.js"), {
workerData: object
});
worker.on("message", (data) => {
resolve({
buffer: Buffer.from([...data.buffer]),
type: data.fileExtension
});
});
worker.on("error", reject);
}
});
};