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