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}`);