cardgen/index.js

174 lines
4.4 KiB
JavaScript

import express from 'express';
import { Resvg } from '@resvg/resvg-js';
import * as fs from 'fs/promises';
import { escape } from 'html-escaper';
import { request } from 'undici';
import { lookup as mimeLookup } from 'mime-types';
const errorSVG = 'cards/error.svg';
const cards = {
player: {
cards: {
front: {
name: 'cards/player-front.svg',
parameters: [
{ name: 'name', type: 'string' },
{ name: 'dmg', type: 'number' },
{ name: 'hp', type: 'number' },
{ name: 'image', type: 'image', optional: true },
],
},
back: {
name: 'cards/skilled-back.png',
ignoreParameters: true,
}
},
special: {
help: 'cards/player-help.svg',
},
}
}
async function svg(filename, params) {
let svg = await fs.readFile(filename, 'utf8');
for (const [key, value] of Object.entries(params)) {
svg = svg.replaceAll('$' + key + '$', escape(value));
}
return svg;
}
async function render(svg) {
const opts = {
font: {
fontFiles: [
'fonts/Atkinson-Hyperlegible-Bold.otf',
'fonts/Atkinson-Hyperlegible-BoldItalic.otf',
'fonts/Atkinson-Hyperlegible-Italic.otf',
'fonts/Atkinson-Hyperlegible-Regular.otf',
'fonts/Minecraft.otf',
'fonts/Minecraft-Bold.otf',
],
loadSystemFonts: false, // It will be faster to disable loading system fonts.
defaultFontFamily: 'Atkinson Hyperlegible',
},
};
const resvg = new Resvg(svg, opts);
const pngData = resvg.render();
const pngBuffer = pngData.asPng();
return pngBuffer;
}
const server = express();
async function returns(req, res, filename, params) {
const ext = req.params.ext;
const s = await svg(filename, params);
if (ext === 'png') {
const r = await render(s);
res.type('png');
res.setHeader('Content-Length', r.byteLength);
res.send(r);
} else if (ext === 'svg') {
res.type('svg');
res.setHeader('Content-Length', s.length);
res.send(s);
} else {
res.status(400).send(`Unknown filetype '${ext}'`);
}
}
async function err(req, res, error) {
returns(req, res, errorSVG, { error });
}
const userAgent = 'cardgen/0.0.0 (https://oat.zone/)';
async function resolveImg(url) {
const { body, statusCode } = await request(url, {
headers: { 'user-agent': userAgent },
});
if (statusCode !== 200) throw `Image returned non-200 error code`;
const blob = await body.blob();
const arrayBuffer = await blob.arrayBuffer();
const b64 = Buffer.from(arrayBuffer).toString('base64');
const mime = mimeLookup(url) || 'image/png';
return 'data:' + mime + ';base64,' + b64;
}
function resolveNumber(str) {
const int = parseInt(str);
if (isNaN(int)) throw 'Invalid number provided';
return int;
}
server.get('/:type/:name.:ext', async (req, res) => {
if (req.headers['user-agent'] === userAgent) return err(req, res, 'Stop that.');
const type = req.params.type;
const name = req.params.name;
const ext = req.params.ext;
const card = cards[type];
if (!card) return err(req, res, `No card named ${type} found!`);
const tex = card.cards[name];
if (!tex) return err(req, res, `Card ${type} does not have ${name}`);
let params = {};
if (card.special.help && Object.entries(req.query).length === 0 && tex.parameters)
return returns(req, res, card.special.help, {});
if (tex.name.endsWith('.png')) {
if (ext !== 'png') {
res.status(400).send(`Cannot offer ${ext}; only a png of this texture xists`);
}
return res.sendFile(tex.name, { root: './' });
}
for (const param of (tex.parameters || [])) {
const name = param.name;
const type = param.type;
const isOptional = param.optional;
const value = req.query[name];
if (!isOptional && (value === undefined || value === null || value === '')) {
return err(req, res, `Required argument ${name} missing`);
}
try {
switch (type) {
case 'string':
params[name] = value || '';
break;
case 'number':
params[name] = value ? resolveNumber(value) : 0;
break;
case 'image':
params[name] = value ? await resolveImg(value) : '';
break;
}
} catch(e) {
return err(req, res, `${name}: ${e}`);
}
}
await returns(req, res, tex.name, params);
});
const port = process.env.PORT || 8080;
server.listen(port);
console.log(`listening on port ${port}`);