Spaces:
Running
Running
File size: 5,926 Bytes
0d53e7e 6cce677 0d53e7e 8c7f41d 0d53e7e bc3d938 0d53e7e a4ad7d1 0d53e7e 6cce677 bc3d938 6cce677 3f34196 6cce677 bc3d938 0d53e7e 8c7f41d bc3d938 6cce677 8c7f41d 6cce677 8c7f41d 0d53e7e 6cce677 0d53e7e a4ad7d1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | import "dotenv/config";
import express from "express";
import { paymentMiddlewareFromConfig } from "@x402/express";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { ExactStellarScheme } from "@x402/stellar/exact/server";
import { Transaction, Networks, Address } from "@stellar/stellar-sdk";
import { renderUrl, closeBrowser } from "./renderer.js";
import { isFailedRender, sendRefund } from "./refund.js";
const PORT = process.env.PORT || 3001;
const PRICE = "$0.001";
const REFUND_AMOUNT = "0.001";
const NETWORK = "stellar:testnet";
const FACILITATOR_URL = "https://www.x402.org/facilitator";
const PAY_TO = process.env.PAY_TO;
if (!PAY_TO) {
console.error("ERROR: PAY_TO not set in .env");
process.exit(1);
}
function isAllowedUrl(urlStr) {
try {
const parsed = new URL(urlStr);
if (!["http:", "https:"].includes(parsed.protocol)) return false;
const blocked = ["localhost", "127.0.0.1", "0.0.0.0", "[::1]"];
if (blocked.includes(parsed.hostname)) return false;
if (parsed.hostname.startsWith("[")) return false; // block all IPv6 literals
const parts = parsed.hostname.split(".");
if (parts[0] === "10") return false;
if (parts[0] === "172" && +parts[1] >= 16 && +parts[1] <= 31) return false;
if (parts[0] === "192" && parts[1] === "168") return false;
if (parts[0] === "169" && parts[1] === "254") return false;
return true;
} catch {
return false;
}
}
// Extract payer from the payment-signature request header (Soroban auth entry)
function getPayerAddress(req) {
try {
const header = req.get("payment-signature") || req.get("x-payment");
if (!header) return null;
const decoded = JSON.parse(Buffer.from(header, "base64").toString());
const txXdr = decoded?.payload?.transaction;
if (!txXdr) return null;
const tx = new Transaction(txXdr, Networks.TESTNET);
const op = tx.operations[0];
if (op?.auth) {
for (const a of op.auth) {
const creds = a.credentials();
if (creds.switch().name === "sorobanCredentialsAddress") {
return Address.fromScAddress(creds.address().address()).toString();
}
}
}
return null;
} catch {
return null;
}
}
const app = express();
// Info endpoint (free)
app.get("/", (_, res) =>
res.json({
service: "RenderGate",
description: "Pay-per-render headless browser API on Stellar x402",
price: PRICE,
network: NETWORK,
usage: "GET /render?url=<encoded_url>",
}),
);
// Health check (free)
app.get("/health", (_, res) => res.json({ status: "ok" }));
// URL validation β runs before payment to reject SSRF attempts early
app.use("/render", (req, res, next) => {
const url = req.query.url;
if (!url) return res.status(400).json({ error: "Missing ?url= parameter" });
let decoded;
try {
decoded = decodeURIComponent(url);
} catch {
return res.status(400).json({ error: "Malformed URL encoding" });
}
if (!isAllowedUrl(decoded)) {
return res
.status(400)
.json({ error: "URL not allowed β only public http/https URLs" });
}
req.decodedUrl = decoded;
next();
});
// x402 payment middleware β protects /render
app.use(
paymentMiddlewareFromConfig(
{
"GET /render": {
accepts: {
scheme: "exact",
price: PRICE,
network: NETWORK,
payTo: PAY_TO,
maxTimeoutSeconds: 60,
},
description: "Render a JS-heavy webpage and return extracted content",
},
},
new HTTPFacilitatorClient({ url: FACILITATOR_URL }),
[{ network: NETWORK, server: new ExactStellarScheme() }],
),
);
// Protected render endpoint
app.get("/render", async (req, res) => {
const decoded = req.decodedUrl;
try {
console.log(`Rendering: ${decoded}`);
const start = Date.now();
const result = await renderUrl(decoded);
const elapsed = Date.now() - start;
const failReason = isFailedRender(result.content, result.title);
if (failReason) {
const payerAddress = getPayerAddress(req);
let refund = null;
if (payerAddress) {
console.log(`Bad render (${failReason}) for ${decoded} β refunding ${payerAddress}`);
const refundHash = await sendRefund(payerAddress, REFUND_AMOUNT, `refund:${failReason}`);
refund = refundHash
? { transaction: refundHash, amount: `${REFUND_AMOUNT} USDC`, reason: failReason }
: { error: "Refund failed β contact support", reason: failReason };
}
return res.json({
...result,
renderTimeMs: elapsed,
payment: { price: PRICE, network: NETWORK },
refund,
});
}
res.json({
...result,
renderTimeMs: elapsed,
payment: { price: PRICE, network: NETWORK },
});
} catch (err) {
console.error(`Render failed for ${decoded}:`, err.message);
if (err.message.includes("Too many concurrent")) {
return res.status(503).json({ error: err.message });
}
// Attempt refund on crash
const payerAddress = getPayerAddress(req);
let refund = null;
if (payerAddress) {
const refundHash = await sendRefund(payerAddress, REFUND_AMOUNT, "refund:render_crash");
refund = refundHash
? { transaction: refundHash, amount: `${REFUND_AMOUNT} USDC`, reason: "render_crash" }
: { error: "Refund failed β contact support" };
}
res.status(500).json({ error: "Render failed", message: err.message, refund });
}
});
const server = app.listen(Number(PORT), () => {
console.log(`RenderGate listening on http://localhost:${PORT}`);
console.log(` Pay ${PRICE} USDC on ${NETWORK} per render`);
console.log(` Payments go to ${PAY_TO}`);
});
for (const sig of ["SIGTERM", "SIGINT"]) {
process.on(sig, async () => {
console.log(`${sig} received, shutting down...`);
server.close();
await closeBrowser();
process.exit(0);
});
}
|