All checks were successful
Deploy Production (qr.crewsportswear.app) / deploy (push) Successful in 31s
132 lines
3.3 KiB
JavaScript
132 lines
3.3 KiB
JavaScript
const express = require("express");
|
|
const QRCode = require("qrcode");
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
const TRUST_PROXY = process.env.TRUST_PROXY !== "false";
|
|
const ALLOWED_QR_IPS = new Set(
|
|
(process.env.ALLOWED_QR_IPS || "")
|
|
.split(",")
|
|
.map((ip) => ip.trim())
|
|
.filter(Boolean)
|
|
.map((ip) => normalizeIp(ip))
|
|
);
|
|
|
|
app.set("trust proxy", TRUST_PROXY);
|
|
|
|
app.use(express.json({ limit: "100kb" }));
|
|
|
|
function normalizeIp(ip) {
|
|
if (!ip || typeof ip !== "string") {
|
|
return "";
|
|
}
|
|
|
|
const trimmed = ip.trim();
|
|
const withoutMappedV4Prefix = trimmed.startsWith("::ffff:") ? trimmed.slice(7) : trimmed;
|
|
|
|
const ipv4WithPortMatch = withoutMappedV4Prefix.match(/^(\d+\.\d+\.\d+\.\d+):(\d+)$/);
|
|
if (ipv4WithPortMatch) {
|
|
return ipv4WithPortMatch[1];
|
|
}
|
|
|
|
return withoutMappedV4Prefix;
|
|
}
|
|
|
|
function getClientIp(req) {
|
|
const forwardedFor = req.headers["x-forwarded-for"];
|
|
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
|
|
return normalizeIp(forwardedFor.split(",")[0]);
|
|
}
|
|
|
|
return normalizeIp(req.ip || req.socket?.remoteAddress || "");
|
|
}
|
|
|
|
function enforceQrIpAllowlist(req, res, next) {
|
|
if (ALLOWED_QR_IPS.size === 0) {
|
|
return next();
|
|
}
|
|
|
|
const clientIp = getClientIp(req);
|
|
if (ALLOWED_QR_IPS.has(clientIp)) {
|
|
return next();
|
|
}
|
|
|
|
return res.status(403).json({ error: "Access denied for this IP address" });
|
|
}
|
|
|
|
function parseIntWithBounds(value, fallback, min, max) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isFinite(parsed)) {
|
|
return fallback;
|
|
}
|
|
return Math.min(max, Math.max(min, parsed));
|
|
}
|
|
|
|
function normalizePayload(source) {
|
|
const payload = source || {};
|
|
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
|
|
if (!text) {
|
|
return {
|
|
error: "Missing required parameter: text",
|
|
};
|
|
}
|
|
|
|
return {
|
|
text,
|
|
width: parseIntWithBounds(payload.size, 512, 128, 2048),
|
|
margin: parseIntWithBounds(payload.margin, 2, 0, 10),
|
|
dark: typeof payload.dark === "string" && payload.dark ? payload.dark : "#000000",
|
|
light: typeof payload.light === "string" && payload.light ? payload.light : "#FFFFFF",
|
|
};
|
|
}
|
|
|
|
async function sendQrPng(source, res, next) {
|
|
const normalized = normalizePayload(source);
|
|
|
|
if (normalized.error) {
|
|
return res.status(400).json({ error: normalized.error });
|
|
}
|
|
|
|
try {
|
|
const image = await QRCode.toBuffer(normalized.text, {
|
|
type: "png",
|
|
width: normalized.width,
|
|
margin: normalized.margin,
|
|
color: {
|
|
dark: normalized.dark,
|
|
light: normalized.light,
|
|
},
|
|
});
|
|
|
|
res.setHeader("Content-Type", "image/png");
|
|
res.setHeader("Cache-Control", "no-store");
|
|
return res.send(image);
|
|
} catch (error) {
|
|
return next(error);
|
|
}
|
|
}
|
|
|
|
app.get("/", (_req, res) => {
|
|
res.json({
|
|
service: "qr-code-api",
|
|
usage: "GET /api/qr?text=hello or POST /api/qr with JSON body { text }",
|
|
});
|
|
});
|
|
|
|
app.get("/health", (_req, res) => {
|
|
res.json({ status: "ok" });
|
|
});
|
|
|
|
app.get("/api/qr", enforceQrIpAllowlist, (req, res, next) => sendQrPng(req.query, res, next));
|
|
app.post("/api/qr", enforceQrIpAllowlist, (req, res, next) => sendQrPng(req.body, res, next));
|
|
|
|
app.use((err, _req, res, _next) => {
|
|
console.error(err);
|
|
res.status(500).json({ error: "Failed to generate QR code" });
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`QR API listening on port ${PORT}`);
|
|
});
|