const path = require('path'); const express = require('express'); const javascriptStringify = require('javascript-stringify').stringify; const qs = require('qs'); const rateLimit = require('express-rate-limit'); const text2png = require('text2png'); const packageJson = require('./package.json'); const telemetry = require('./telemetry'); const { getPdfBufferFromPng, getPdfBufferWithText } = require('./lib/pdf'); const { logger } = require('./logging'); const { renderChartJs } = require('./lib/charts'); const { renderGraphviz } = require('./lib/graphviz'); const { toChartJs, parseSize } = require('./lib/google_image_charts'); const { renderQr, DEFAULT_QR_SIZE } = require('./lib/qr'); const app = express(); const isDev = app.get('env') === 'development' || app.get('env') === 'test'; app.set('query parser', (str) => qs.parse(str, { decode(s) { // Default express implementation replaces '+' with space. We don't want // that. See https://github.com/expressjs/express/issues/3453 return decodeURIComponent(s); }, }), ); app.use( express.json({ limit: process.env.EXPRESS_JSON_LIMIT || '100kb', }), ); app.use(express.urlencoded()); if (process.env.RATE_LIMIT_PER_MIN) { const limitMax = parseInt(process.env.RATE_LIMIT_PER_MIN, 10); logger.info('Enabling rate limit:', limitMax); const limiter = rateLimit({ windowMs: 60 * 1000, max: limitMax, message: 'Please slow down your requests! This is a shared public endpoint. Email support@quickchart.io or go to https://quickchart.io/pricing/ for rate limit exceptions or to purchase a commercial license.', onLimitReached: (req) => { logger.info('User hit rate limit!', req.ip); }, keyGenerator: (req) => { return req.headers['x-forwarded-for'] || req.ip; }, }); app.use('/chart', limiter); } app.get('/', (req, res) => { res.send( 'QuickChart is running!

If you are using QuickChart commercially, please consider purchasing a license to support the project.', ); }); app.post('/telemetry', (req, res) => { const chartCount = parseInt(req.body.chartCount, 10); const qrCount = parseInt(req.body.qrCount, 10); const pid = req.body.pid; if (chartCount && !isNaN(chartCount)) { telemetry.receive(pid, 'chartCount', chartCount); } if (qrCount && !isNaN(qrCount)) { telemetry.receive(pid, 'qrCount', qrCount); } res.send({ success: true }); }); function utf8ToAscii(str) { const enc = new TextEncoder(); const u8s = enc.encode(str); return Array.from(u8s) .map((v) => String.fromCharCode(v)) .join(''); } function sanitizeErrorHeader(msg) { if (typeof msg === 'string') { return utf8ToAscii(msg).replace(/\r?\n|\r/g, ''); } return ''; } function failPng(res, msg, statusCode = 500) { res.writeHead(statusCode, { 'Content-Type': 'image/png', 'X-quickchart-error': sanitizeErrorHeader(msg), }); res.end( text2png(`Chart Error: ${msg}`, { padding: 10, backgroundColor: '#fff', }), ); } function failSvg(res, msg, statusCode = 500) { res.writeHead(statusCode, { 'Content-Type': 'image/svg+xml', 'X-quickchart-error': sanitizeErrorHeader(msg), }); res.end(`

${msg}

`); } async function failPdf(res, msg) { const buf = await getPdfBufferWithText(msg); res.writeHead(500, { 'Content-Type': 'application/pdf', 'X-quickchart-error': sanitizeErrorHeader(msg), }); res.end(buf); } function renderChartToPng(req, res, opts) { opts.failFn = failPng; opts.onRenderHandler = (buf) => { res .type('image/png') .set({ // 1 week cache 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800', }) .send(buf) .end(); }; doChartjsRender(req, res, opts); } function renderChartToSvg(req, res, opts) { opts.failFn = failSvg; opts.onRenderHandler = (buf) => { res .type('image/svg+xml') .set({ // 1 week cache 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800', }) .send(buf) .end(); }; doChartjsRender(req, res, opts); } async function renderChartToPdf(req, res, opts) { opts.failFn = failPdf; opts.onRenderHandler = async (buf) => { const pdfBuf = await getPdfBufferFromPng(buf); res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Length': pdfBuf.length, // 1 week cache 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800', }); res.end(pdfBuf); }; doChartjsRender(req, res, opts); } function doChartjsRender(req, res, opts) { if (!opts.chart) { opts.failFn(res, 'You are missing variable `c` or `chart`'); return; } const width = parseInt(opts.width, 10) || 500; const height = parseInt(opts.height, 10) || 300; let untrustedInput = opts.chart; if (opts.encoding === 'base64') { // TODO(ian): Move this decoding up the call stack. try { untrustedInput = Buffer.from(opts.chart, 'base64').toString('utf8'); } catch (err) { logger.warn('base64 malformed', err); opts.failFn(res, err); return; } } renderChartJs( width, height, opts.backgroundColor, opts.devicePixelRatio, opts.version || '2.9.4', opts.format, untrustedInput, ) .then(opts.onRenderHandler) .catch((err) => { logger.warn('Chart error', err); opts.failFn(res, err); }); } async function handleGraphviz(req, res, graphVizDef, opts) { try { const buf = await renderGraphviz(req.query.chl, opts); res .status(200) .type(opts.format === 'png' ? 'image/png' : 'image/svg+xml') .end(buf); } catch (err) { if (opts.format === 'png') { failPng(res, `Graph Error: ${err}`); } else { failSvg(res, `Graph Error: ${err}`); } } } function handleGChart(req, res) { // TODO(ian): Move these special cases into Google Image Charts-specific // handler. if (req.query.cht.startsWith('gv')) { // Graphviz chart const format = req.query.chof; const engine = req.query.cht.indexOf(':') > -1 ? req.query.cht.split(':')[1] : 'dot'; const opts = { format, engine, }; if (req.query.chs) { const size = parseSize(req.query.chs); opts.width = size.width; opts.height = size.height; } handleGraphviz(req, res, req.query.chl, opts); return; } else if (req.query.cht === 'qr') { const size = parseInt(req.query.chs.split('x')[0], 10); const qrData = req.query.chl; const chldVals = (req.query.chld || '').split('|'); const ecLevel = chldVals[0] || 'L'; const margin = chldVals[1] || 4; const qrOpts = { margin: margin, width: size, errorCorrectionLevel: ecLevel, }; const format = 'png'; const encoding = 'UTF-8'; renderQr(format, encoding, qrData, qrOpts) .then((buf) => { res.writeHead(200, { 'Content-Type': format === 'png' ? 'image/png' : 'image/svg+xml', 'Content-Length': buf.length, // 1 week cache 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800', }); res.end(buf); }) .catch((err) => { failPng(res, err); }); telemetry.count('qrCount'); return; } let converted; try { converted = toChartJs(req.query); } catch (err) { logger.error(`GChart error: Could not interpret ${req.originalUrl}`); res.status(500).end('Sorry, this chart configuration is not supported right now'); return; } if (req.query.format === 'chartjs-config') { // Chart.js config res.writeHead(200, { 'Content-Type': 'application/json', }); res.end(javascriptStringify(converted.chart, undefined, 2)); return; } renderChartJs( converted.width, converted.height, converted.backgroundColor, 1.0 /* devicePixelRatio */, '2.9.4' /* version */, undefined /* format */, converted.chart, ).then((buf) => { res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': buf.length, // 1 week cache 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800', }); res.end(buf); }); telemetry.count('chartCount'); } app.get('/chart', (req, res) => { if (req.query.cht) { // This is a Google Image Charts-compatible request. handleGChart(req, res); return; } const outputFormat = (req.query.f || req.query.format || 'png').toLowerCase(); const opts = { chart: req.query.c || req.query.chart, height: req.query.h || req.query.height, width: req.query.w || req.query.width, backgroundColor: req.query.backgroundColor || req.query.bkg, devicePixelRatio: req.query.devicePixelRatio, version: req.query.v || req.query.version, encoding: req.query.encoding || 'url', format: outputFormat, }; if (outputFormat === 'pdf') { renderChartToPdf(req, res, opts); } else if (outputFormat === 'svg') { renderChartToSvg(req, res, opts); } else if (!outputFormat || outputFormat === 'png') { renderChartToPng(req, res, opts); } else { logger.error(`Request for unsupported format ${outputFormat}`); res.status(500).end(`Unsupported format ${outputFormat}`); } telemetry.count('chartCount'); }); app.post('/chart', (req, res) => { const outputFormat = (req.body.f || req.body.format || 'png').toLowerCase(); const opts = { chart: req.body.c || req.body.chart, height: req.body.h || req.body.height, width: req.body.w || req.body.width, backgroundColor: req.body.backgroundColor || req.body.bkg, devicePixelRatio: req.body.devicePixelRatio, version: req.body.v || req.body.version, encoding: req.body.encoding || 'url', format: outputFormat, }; if (outputFormat === 'pdf') { renderChartToPdf(req, res, opts); } else if (outputFormat === 'svg') { renderChartToSvg(req, res, opts); } else { renderChartToPng(req, res, opts); } telemetry.count('chartCount'); }); app.get('/qr', (req, res) => { const qrText = req.query.text; if (!qrText) { failPng(res, 'You are missing variable `text`'); return; } let format = 'png'; if (req.query.format === 'svg') { format = 'svg'; } const { mode } = req.query; const margin = typeof req.query.margin === 'undefined' ? 4 : parseInt(req.query.margin, 10); const ecLevel = req.query.ecLevel || undefined; const size = Math.min(3000, parseInt(req.query.size, 10)) || DEFAULT_QR_SIZE; const darkColor = req.query.dark || '000'; const lightColor = req.query.light || 'fff'; const qrOpts = { margin, width: size, errorCorrectionLevel: ecLevel, color: { dark: darkColor, light: lightColor, }, }; renderQr(format, mode, qrText, qrOpts) .then((buf) => { res.writeHead(200, { 'Content-Type': format === 'png' ? 'image/png' : 'image/svg+xml', 'Content-Length': buf.length, // 1 week cache 'Cache-Control': isDev ? 'no-cache' : 'public, max-age=604800', }); res.end(buf); }) .catch((err) => { failPng(res, err); }); telemetry.count('qrCount'); }); app.get('/gchart', handleGChart); app.get('/healthcheck', (req, res) => { // A lightweight healthcheck endpoint. res.send({ success: true, version: packageJson.version }); }); app.get('/healthcheck/chart', (req, res) => { // A heavier healthcheck endpoint that redirects to a unique chart. const labels = [...Array(5)].map(() => Math.random()); const data = [...Array(5)].map(() => Math.random()); const template = ` { type: 'bar', data: { labels: [${labels.join(',')}], datasets: [{ data: [${data.join(',')}] }] } } `; res.redirect(`/chart?c=${template}`); }); const port = process.env.PORT || 3400; const server = app.listen(port); const timeout = parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 5000; server.setTimeout(timeout); logger.info(`Setting request timeout: ${timeout} ms`); logger.info(`NODE_ENV: ${process.env.NODE_ENV}`); logger.info(`Listening on port ${port}`); if (!isDev) { const gracefulShutdown = function gracefulShutdown() { logger.info('Received kill signal, shutting down gracefully.'); server.close(() => { logger.info('Closed out remaining connections.'); process.exit(); }); setTimeout(() => { logger.error('Could not close connections in time, forcefully shutting down'); process.exit(); }, 10 * 1000); }; // listen for TERM signal .e.g. kill process.on('SIGTERM', gracefulShutdown); // listen for INT signal e.g. Ctrl-C process.on('SIGINT', gracefulShutdown); process.on('SIGABRT', () => { logger.info('Caught SIGABRT'); }); } module.exports = app;