Initial commit: SolLunar Kitchen under version control
This commit is contained in:
commit
601c85f236
37 changed files with 6206 additions and 0 deletions
425
server/server.js
Normal file
425
server/server.js
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
/**
|
||||
* KITCHEN 484 / SOLARPUNK SUMMIT — BTCPay payment backend
|
||||
* ------------------------------------------------------------------
|
||||
* A tiny Node (no dependencies) proxy that:
|
||||
* - keeps the BTCPay API key SERVER-SIDE (never sent to the browser)
|
||||
* - creates BTCPay invoices (onchain BTC + Lightning bolt11)
|
||||
* - reports status via polling AND Server-Sent Events
|
||||
* - receives BTCPay webhooks (HMAC-SHA256 verified via BTCPay-Sig)
|
||||
* - serves the static site from the parent directory
|
||||
*
|
||||
* Env (also persisted to .env next to this file):
|
||||
* PORT listen port (default 8787)
|
||||
* BTCPAY_URL e.g. https://btcpay.example.com
|
||||
* BTCPAY_STORE store id
|
||||
* BTCPAY_API_KEY store api key (token)
|
||||
* WEBHOOK_SECRET secret used by BTCPay to sign webhook deliveries
|
||||
* WEBHOOK_PUBLIC_URL your public origin, e.g. https://kitchen.example.com
|
||||
* (used when auto-registering the webhook)
|
||||
* DATA_FILE where invoices are persisted (default .data/invoices.json)
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STATIC_DIR = path.resolve(__dirname, '..');
|
||||
const ENV_FILE = path.join(__dirname, '.env');
|
||||
|
||||
/* ----------------------------- config ----------------------------- */
|
||||
function loadEnv() {
|
||||
if (!fs.existsSync(ENV_FILE)) return {};
|
||||
const out = {};
|
||||
for (const line of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||||
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function saveEnv(obj) {
|
||||
const lines = Object.entries(obj).map(([k, v]) => `${k}=${v}`);
|
||||
fs.writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
|
||||
}
|
||||
const cfg = {
|
||||
port: Number(process.env.PORT || 8787),
|
||||
url: process.env.BTCPAY_URL || '',
|
||||
store: process.env.BTCPAY_STORE || '',
|
||||
apiKey: process.env.BTCPAY_API_KEY || '',
|
||||
webhookSecret: process.env.WEBHOOK_SECRET || '',
|
||||
publicUrl: process.env.WEBHOOK_PUBLIC_URL || '',
|
||||
dataFile: process.env.DATA_FILE || path.join(__dirname, '.data/invoices.json'),
|
||||
};
|
||||
Object.assign(cfg, loadEnv());
|
||||
|
||||
function configured() {
|
||||
return Boolean(cfg.url && cfg.store && cfg.apiKey);
|
||||
}
|
||||
|
||||
/* --------------------------- persistence --------------------------- */
|
||||
const invoices = new Map(); // btcpayInvoiceId -> record
|
||||
function loadInvoices() {
|
||||
try {
|
||||
for (const rec of JSON.parse(fs.readFileSync(cfg.dataFile, 'utf8'))) {
|
||||
invoices.set(rec.id, rec);
|
||||
}
|
||||
} catch { /* first run */ }
|
||||
}
|
||||
function persist() {
|
||||
fs.mkdirSync(path.dirname(cfg.dataFile), { recursive: true });
|
||||
fs.writeFileSync(cfg.dataFile, JSON.stringify([...invoices.values()], null, 2));
|
||||
}
|
||||
loadInvoices();
|
||||
|
||||
/* ------------------------- BTCPay API client ------------------------ */
|
||||
async function btcpay(pathname, { method = 'GET', body } = {}) {
|
||||
const base = cfg.url.replace(/\/+$/, '');
|
||||
const res = await fetch(base + pathname, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'token ' + cfg.apiKey,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
||||
if (!res.ok) {
|
||||
const err = new Error(`BTCPay ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/* ------------------------------ helpers ----------------------------- */
|
||||
const PAID = new Set(['Paid', 'Complete', 'Settled', 'Confirmed']);
|
||||
const DEAD = new Set(['Expired', 'Invalid', 'Cancelled', 'Failed']);
|
||||
function normStatus(s) {
|
||||
s = String(s || 'New');
|
||||
if (PAID.has(s)) return 'Paid';
|
||||
if (DEAD.has(s)) return 'Expired';
|
||||
return s; // New | Processing | …
|
||||
}
|
||||
function sseClients() {
|
||||
// Map invoiceId -> Set<res>; plus a '*' key for global listeners
|
||||
return global.__sse;
|
||||
}
|
||||
const sse = new Map();
|
||||
global.__sse = sse;
|
||||
function broadcast(id, payload) {
|
||||
for (const key of [id, '*']) {
|
||||
const set = sse.get(key);
|
||||
if (!set) continue;
|
||||
const msg = `data: ${JSON.stringify(payload)}\n\n`;
|
||||
for (const res of set) { try { res.write(msg); } catch { set.delete(res); } }
|
||||
}
|
||||
}
|
||||
function updateInvoice(id, status, extra = {}) {
|
||||
const rec = invoices.get(id);
|
||||
if (!rec) return;
|
||||
const next = normStatus(status);
|
||||
if (next !== rec.status) {
|
||||
rec.status = next;
|
||||
rec.updatedAt = Date.now();
|
||||
persist();
|
||||
broadcast(id, { type: 'status', id, status: next });
|
||||
}
|
||||
Object.assign(rec, extra);
|
||||
persist();
|
||||
}
|
||||
|
||||
/* simple per-IP rate limit for invoice creation */
|
||||
const hitTimes = new Map();
|
||||
function rateLimit(ip, max = 10, windowMs = 60_000) {
|
||||
const now = Date.now();
|
||||
const arr = (hitTimes.get(ip) || []).filter(t => now - t < windowMs);
|
||||
if (arr.length >= max) return false;
|
||||
arr.push(now);
|
||||
hitTimes.set(ip, arr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ----------------------------- static ------------------------------ */
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript',
|
||||
'.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json',
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp', '.ico': 'image/x-icon', '.txt': 'text/plain',
|
||||
};
|
||||
function serveStatic(req, res, url) {
|
||||
let p = decodeURIComponent(url.pathname);
|
||||
if (p === '/') p = '/index.html';
|
||||
const file = path.normalize(path.join(STATIC_DIR, p));
|
||||
if (!file.startsWith(STATIC_DIR)) { res.writeHead(403); return res.end('forbidden'); }
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found'); }
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------ http ------------------------------- */
|
||||
function json(res, code, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const ip = req.socket.remoteAddress || 'unknown';
|
||||
try {
|
||||
/* CORS preflight */
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
});
|
||||
return res.end();
|
||||
}
|
||||
|
||||
const p = url.pathname;
|
||||
|
||||
/* ---------- health ---------- */
|
||||
if (p === '/health') {
|
||||
return json(res, 200, { ok: true, configured: configured(), time: Date.now() });
|
||||
}
|
||||
|
||||
/* ---------- settings ---------- */
|
||||
if (p === '/api/settings' && req.method === 'GET') {
|
||||
return json(res, 200, { configured: configured(), url: cfg.url || null, store: cfg.store || null });
|
||||
}
|
||||
if (p === '/api/settings' && req.method === 'POST') {
|
||||
// body: {url, store, apiKey} — validates the connection, then persists
|
||||
const body = await readBody(req);
|
||||
const u = String(body.url || '').trim();
|
||||
const store = String(body.store || '').trim();
|
||||
const key = String(body.apiKey || '').trim();
|
||||
if (!u || !store) return json(res, 400, { ok: false, error: 'url and store are required' });
|
||||
let probe = null;
|
||||
try {
|
||||
const base = u.replace(/\/+$/, '');
|
||||
const r = await fetch(base + '/api/v1/stores/' + encodeURIComponent(store), {
|
||||
headers: { Authorization: 'token ' + key, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const t = await r.text();
|
||||
let j = {}; try { j = JSON.parse(t); } catch { /* ignore */ }
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status} ${t.slice(0, 160)}`);
|
||||
probe = { storeId: j.id, storeName: j.name || null, network: j.network || null };
|
||||
} catch (e) {
|
||||
return json(res, 400, { ok: false, error: 'Could not verify store: ' + e.message });
|
||||
}
|
||||
cfg.url = u; cfg.store = store; cfg.apiKey = key;
|
||||
persistConfig();
|
||||
return json(res, 200, { ok: true, ...probe });
|
||||
}
|
||||
|
||||
/* ---------- webhook secret (manual mode) ---------- */
|
||||
if (p === '/api/webhook-secret' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
cfg.webhookSecret = String(body.secret || '').trim();
|
||||
persistConfig();
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
/* ---------- auto-register webhook ---------- */
|
||||
if (p === '/api/webhook/register' && req.method === 'POST') {
|
||||
if (!configured()) return json(res, 400, { ok: false, error: 'BTCPay not configured' });
|
||||
const publicUrl = cfg.publicUrl ? cfg.publicUrl.replace(/\/+$/, '') : '';
|
||||
if (!publicUrl) return json(res, 400, { ok: false, error: 'Set WEBHOOK_PUBLIC_URL in .env first' });
|
||||
const wh = await btcpay(`/api/v1/stores/${cfg.store}/webhooks`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
url: `${publicUrl}/api/btcpay/webhook`,
|
||||
enabled: true,
|
||||
automaticRedelivery: true,
|
||||
authorizedEvents: { invoiceSettled: true, invoiceExpired: true, invoiceInvalid: true, invoiceReceivedPayment: true },
|
||||
},
|
||||
});
|
||||
cfg.webhookSecret = wh.secret || cfg.webhookSecret;
|
||||
persistConfig();
|
||||
return json(res, 200, { ok: true, webhookId: wh.id, secretSet: Boolean(wh.secret) });
|
||||
}
|
||||
|
||||
/* ---------- create invoice ---------- */
|
||||
if (p === '/api/invoices' && req.method === 'POST') {
|
||||
if (!configured()) return json(res, 503, { error: 'BTCPay not configured — save settings first' });
|
||||
if (!rateLimit(ip)) return json(res, 429, { error: 'Too many invoices, slow down' });
|
||||
const body = await readBody(req);
|
||||
const amount = Number(body.amount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return json(res, 400, { error: 'amount must be a positive number' });
|
||||
const currency = String(body.currency || 'USD').toUpperCase();
|
||||
const orderCode = String(body.orderCode || '').slice(0, 64);
|
||||
const description = String(body.description || 'KITCHEN 484 order').slice(0, 512);
|
||||
const inv = await btcpay(`/api/v1/stores/${cfg.store}/invoices`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
amount: Math.round(amount * 1e8) / 1e8,
|
||||
currency,
|
||||
description,
|
||||
expirationInterval: 30 * 60, // 30 min
|
||||
metadata: { orderCode, ...pick(body.metadata, ['pickup', 'name', 'items', 'day']) },
|
||||
},
|
||||
});
|
||||
const id = inv.id;
|
||||
const rec = {
|
||||
id,
|
||||
orderCode,
|
||||
amount,
|
||||
currency,
|
||||
status: normStatus(inv.status),
|
||||
bolt11: extractBolt11(inv),
|
||||
btcAddress: extractBtcAddress(inv),
|
||||
checkoutUrl: inv.checkoutUrl || inv.paymentUrl || null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
invoices.set(id, rec);
|
||||
persist();
|
||||
broadcast(id, { type: 'created', id, status: rec.status });
|
||||
return json(res, 201, publicInvoice(rec));
|
||||
}
|
||||
|
||||
/* ---------- invoice status (polling) ---------- */
|
||||
let m;
|
||||
if ((m = p.match(/^\/api\/invoices\/([^/]+)$/)) && req.method === 'GET') {
|
||||
const rec = invoices.get(m[1]);
|
||||
if (!rec) return json(res, 404, { error: 'unknown invoice' });
|
||||
return json(res, 200, publicInvoice(rec));
|
||||
}
|
||||
|
||||
/* ---------- invoice status (SSE) ---------- */
|
||||
if ((m = p.match(/^\/api\/invoices\/([^/]+)\/events$/)) && req.method === 'GET') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
res.write(`retry: 3000\n\n`);
|
||||
const id = m[1];
|
||||
const set = new Set([res]);
|
||||
sse.set(id, set);
|
||||
const rec = invoices.get(id);
|
||||
if (rec) res.write(`data: ${JSON.stringify({ type: 'status', id, status: rec.status })}\n\n`);
|
||||
const ping = setInterval(() => { try { res.write(`: ping\n\n`); } catch { /* closed */ } }, 25_000);
|
||||
req.on('close', () => {
|
||||
clearInterval(ping);
|
||||
set.delete(res);
|
||||
if (set.size === 0) sse.delete(id);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* ---------- BTCPay webhook ---------- */
|
||||
if (p === '/api/btcpay/webhook' && req.method === 'POST') {
|
||||
const raw = await readRaw(req);
|
||||
const sig = req.headers['btcpay-sig'];
|
||||
if (!cfg.webhookSecret || !verifyBtcpaySig(raw, sig, cfg.webhookSecret)) {
|
||||
return json(res, 401, { error: 'bad signature' });
|
||||
}
|
||||
let data; try { data = JSON.parse(raw.toString('utf8')); } catch { data = {}; }
|
||||
const invId = data.invoice; // BTCPay sends the invoice id in the payload
|
||||
const rec = invId && invoices.get(String(invId));
|
||||
if (!rec) {
|
||||
// Could be an invoice created before a restart; accept and log it.
|
||||
console.log('[webhook] unknown invoice', invId, 'event', data.event);
|
||||
return json(res, 200, { ok: true, unknown: true });
|
||||
}
|
||||
const event = String(data.event || '');
|
||||
let status = normStatus(data.status || rec.status);
|
||||
if (event === 'InvoiceSettled' || event === 'invoice_settled') status = 'Paid';
|
||||
if (event === 'InvoiceExpired') status = 'Expired';
|
||||
if (event === 'InvoiceInvalid') status = 'Expired';
|
||||
updateInvoice(rec.id, status, { event, receivedAt: Date.now() });
|
||||
console.log(`[webhook] ${rec.id} ${event} → ${rec.status}`);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
/* ---------- everything else: static site ---------- */
|
||||
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res, url);
|
||||
res.writeHead(405, { 'Access-Control-Allow-Origin': '*' });
|
||||
return res.end('method not allowed');
|
||||
} catch (e) {
|
||||
const code = e.status || 500;
|
||||
console.error('[error]', e.message);
|
||||
return json(res, code, { error: e.message || 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
function persistConfig() {
|
||||
const cur = loadEnv();
|
||||
saveEnv({
|
||||
BTCPAY_URL: cfg.url,
|
||||
BTCPAY_STORE: cfg.store,
|
||||
BTCPAY_API_KEY: cfg.apiKey,
|
||||
WEBHOOK_SECRET: cfg.webhookSecret,
|
||||
WEBHOOK_PUBLIC_URL: cfg.publicUrl,
|
||||
...pick(cur, ['PORT', 'DATA_FILE']),
|
||||
});
|
||||
}
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', c => { data += c; if (data.length > 1e6) { reject(new Error('body too large')); req.destroy(); } });
|
||||
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { reject(new Error('bad json')); } });
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
function readRaw(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', c => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
function pick(obj, keys) {
|
||||
const out = {};
|
||||
for (const k of keys) if (obj && obj[k] !== undefined) out[k] = obj[k];
|
||||
return out;
|
||||
}
|
||||
function publicInvoice(rec) {
|
||||
return {
|
||||
id: rec.id, orderCode: rec.orderCode, amount: rec.amount, currency: rec.currency,
|
||||
status: rec.status, bolt11: rec.bolt11 || null, btcAddress: rec.btcAddress || null,
|
||||
checkoutUrl: rec.checkoutUrl || null, createdAt: rec.createdAt, updatedAt: rec.updatedAt,
|
||||
};
|
||||
}
|
||||
function extractBolt11(inv) {
|
||||
const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'LIGHTNING');
|
||||
return pm && (pm.bolt11 || (pm.data && pm.data.bolt11)) || null;
|
||||
}
|
||||
function extractBtcAddress(inv) {
|
||||
const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'BTC');
|
||||
if (pm && pm.data && pm.data.address) return pm.data.address;
|
||||
if (pm && pm.address) return pm.address;
|
||||
if (typeof inv.paymentAddresses === 'string') return inv.paymentAddresses;
|
||||
if (inv.paymentAddresses && inv.paymentAddresses.BTC) return inv.paymentAddresses.BTC;
|
||||
return null;
|
||||
}
|
||||
/** BTCPay webhook signature: BTCPay-Sig: sha256=<hex hmac of raw body with secret> */
|
||||
function verifyBtcpaySig(rawBody, sigHeader, secret) {
|
||||
if (!sigHeader || !String(sigHeader).startsWith('sha256=')) return false;
|
||||
const given = String(sigHeader).slice('sha256='.length);
|
||||
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(given, 'hex'), Buffer.from(expected, 'hex'));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
server.listen(cfg.port, () => {
|
||||
console.log(`KITCHEN 484 pay backend listening on :${cfg.port}`);
|
||||
console.log(configured()
|
||||
? `BTCPay: ${cfg.url} store=${cfg.store} webhookSecret=${cfg.webhookSecret ? 'set' : 'MISSING'}`
|
||||
: 'BTCPay NOT configured — POST /api/settings with {url, store, apiKey}');
|
||||
if (cfg.publicUrl) console.log(`Public origin for webhook: ${cfg.publicUrl}/api/btcpay/webhook`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue