Spaces:
Running
Running
File size: 5,959 Bytes
dcf8b6b 4ac478a dcf8b6b 4ac478a 4d15eef 4ac478a dcf8b6b | 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 | 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, Address } from "@stellar/stellar-sdk";
import { renderUrl, closeBrowser } from "./renderer.js";
import { isFailedRender, sendRefund } from "./refund.js";
import {
NETWORK,
NETWORK_PASSPHRASE,
FACILITATOR_URL,
FACILITATOR_API_KEY,
} from "./network-config.js";
import { isAllowedUrl } from "./url-policy.js";
const PORT = process.env.PORT || 3001;
const PRICE = "$0.001";
const REFUND_AMOUNT = "0.001";
const PAY_TO = process.env.PAY_TO;
if (!PAY_TO) {
console.error("ERROR: PAY_TO not set in .env");
process.exit(1);
}
// Extract payer from the payment-signature request header.
// x402 payments are exactly one InvokeHostFunction op calling USDC.transfer
// with one auth entry whose address is the payer. Reject anything that doesn't
// match this exact shape β we don't want to refund ambiguous transactions.
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, NETWORK_PASSPHRASE);
if (tx.operations.length !== 1) return null;
const op = tx.operations[0];
if (op.type !== "invokeHostFunction") return null;
if (!op.auth || op.auth.length !== 1) return null;
const creds = op.auth[0].credentials();
if (creds.switch().name !== "sorobanCredentialsAddress") return null;
return Address.fromScAddress(creds.address().address()).toString();
} 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,
...(FACILITATOR_API_KEY && {
// The lib calls this for each operation ("verify"/"settle"/"supported")
// and indexes into the returned object by op name.
createAuthHeaders: async () => {
const auth = { Authorization: `Bearer ${FACILITATOR_API_KEY}` };
return { verify: auth, settle: auth, supported: auth };
},
}),
}),
[{ 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);
});
}
|