You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
510 lines
17 KiB
510 lines
17 KiB
// server.js — IPFS Web backend (ESM) |
|
|
|
import 'dotenv/config'; |
|
import express from 'express'; |
|
import cors from 'cors'; |
|
import { Pool } from 'pg'; |
|
import { ethers } from 'ethers'; |
|
|
|
// 1x1 transparent PNG buffer for the pixel endpoint (needed by /pixel/:doc) |
|
const ONE_BY_ONE_PNG = Buffer.from( |
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wwAAgMBgN2nS8kAAAAASUVORK5CYII=', |
|
'base64' |
|
); |
|
|
|
const { |
|
PORT = '4310', |
|
HOST = '0.0.0.0', |
|
DATABASE_URL, |
|
CORS_ORIGIN, |
|
CHAIN = 'etica-mainnet', |
|
RPC_URL = 'https://rpc.etica-stats.org', |
|
REQUIRED_CONFIRMATIONS = '3', |
|
PAY_TO = '0xc4d275cf97d6a4cc29ede86d16976e808d264732', |
|
PUBLIC_GATEWAY = 'https://ipfs.outsidethebox.top', |
|
EGAZ_PER_ETI = '25', |
|
IPFS_API = 'http://127.0.0.1:5001/api/v0' |
|
} = process.env; |
|
|
|
if (!DATABASE_URL) { console.error('Missing DATABASE_URL'); process.exit(1); } |
|
|
|
const pool = new Pool({ connectionString: DATABASE_URL }); |
|
async function sql(q, p = []) { return pool.query(q, p); } |
|
function toInt(x, d = 0) { const n = parseInt(x, 10); return Number.isFinite(n) ? n : d; } |
|
function nowIso() { return new Date().toISOString(); } |
|
|
|
async function migrate() { |
|
await sql('SELECT 1'); |
|
|
|
// ensure columns we use exist |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS confs INTEGER DEFAULT 0`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS required_confs INTEGER DEFAULT 3`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS mined_block BIGINT`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS latest_block BIGINT`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS tx_hash TEXT`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS cid TEXT`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS path TEXT`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS label TEXT`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS filename TEXT`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS size_bytes BIGINT`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ DEFAULT (now() + interval '30 minutes')`); |
|
await sql(`ALTER TABLE IF EXISTS payments ADD COLUMN IF NOT EXISTS vpath TEXT`); |
|
|
|
// counters table |
|
await sql(` |
|
CREATE TABLE IF NOT EXISTS counters ( |
|
doc TEXT PRIMARY KEY, |
|
cnt BIGINT NOT NULL DEFAULT 0, |
|
updated_at TIMESTAMPTZ DEFAULT now() |
|
) |
|
`); |
|
|
|
// status constraint (reset safely) |
|
await sql(` |
|
DO $$ |
|
BEGIN |
|
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_status_chk') THEN |
|
BEGIN |
|
ALTER TABLE payments DROP CONSTRAINT payments_status_chk; |
|
EXCEPTION WHEN others THEN NULL; |
|
END; |
|
END IF; |
|
END$$; |
|
`); |
|
await sql(` |
|
ALTER TABLE payments |
|
ADD CONSTRAINT payments_status_chk |
|
CHECK (status = ANY (ARRAY['initial','pending','confirmed','paid','failed','expired','cancelled'])) |
|
`); |
|
|
|
// FK to quotes (safe if quotes table exists) |
|
await sql(` |
|
DO $$ |
|
BEGIN |
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_quote_id_fkey') THEN |
|
ALTER TABLE payments |
|
ADD CONSTRAINT payments_quote_id_fkey |
|
FOREIGN KEY (quote_id) REFERENCES upload_quotes(quote_id) ON DELETE SET NULL; |
|
END IF; |
|
END$$; |
|
`); |
|
|
|
await sql(`UPDATE payments SET confs = COALESCE(confs, 0)`); |
|
await sql(`UPDATE payments SET required_confs = COALESCE(required_confs, 3)`); |
|
} |
|
|
|
const app = express(); |
|
app.set('trust proxy', true); |
|
if (CORS_ORIGIN) app.use(cors({ origin: CORS_ORIGIN, credentials: false })); |
|
app.use(express.json({ limit: '50mb' })); |
|
app.use(express.urlencoded({ extended: true })); |
|
|
|
/* ---------------------------- |
|
SET 3: Counter endpoints |
|
---------------------------- */ |
|
|
|
app.get('/pixel/:doc', async (req, res) => { |
|
try { |
|
const doc = String(req.params.doc || '').slice(0, 80); |
|
if (!doc) return res.status(400).end(); |
|
await sql( |
|
`INSERT INTO counters(doc, cnt) VALUES ($1, 1) |
|
ON CONFLICT (doc) DO UPDATE SET cnt = counters.cnt + 1, updated_at = now()`, |
|
[doc] |
|
); |
|
res.setHeader('Content-Type', 'image/png'); |
|
res.setHeader('Cache-Control', 'no-store'); |
|
res.end(ONE_BY_ONE_PNG); |
|
} catch { |
|
res.setHeader('Content-Type', 'image/png'); |
|
res.setHeader('Cache-Control', 'no-store'); |
|
res.end(ONE_BY_ONE_PNG); |
|
} |
|
}); |
|
|
|
app.get('/api/counter', async (req, res) => { |
|
try { |
|
const doc = String(req.query.doc || '').slice(0, 80); |
|
if (!doc) return res.status(400).json({ ok: false, error: 'doc required' }); |
|
const { rows } = await sql(`SELECT cnt FROM counters WHERE doc=$1`, [doc]); |
|
const count = rows[0]?.cnt || 0; |
|
res.json({ ok: true, doc, count }); |
|
} catch (e) { |
|
res.status(500).json({ ok: false, error: String(e) }); |
|
} |
|
}); |
|
|
|
/* ---------------------------- |
|
SET 4: Metrics endpoint mount |
|
---------------------------- */ |
|
let registerMetrics = null; |
|
try { ({ default: registerMetrics } = await import('./metrics.js')); } |
|
catch { try { ({ default: registerMetrics } = await import('./metrics.cjs')); } catch {} } |
|
if (typeof registerMetrics === 'function') registerMetrics(app, { query: sql }); |
|
|
|
/* ---------------------------- */ |
|
|
|
app.get('/api/health', async (_req, res) => { |
|
try { |
|
let latestBlock = null; |
|
try { |
|
const provider = new ethers.JsonRpcProvider(RPC_URL); |
|
latestBlock = await provider.getBlockNumber(); |
|
} catch {} |
|
res.json({ |
|
ok: true, |
|
chain: CHAIN, |
|
rpc: RPC_URL, |
|
latestBlock, |
|
requiredConfirmations: toInt(REQUIRED_CONFIRMATIONS, 3), |
|
payTo: PAY_TO, |
|
now: nowIso(), |
|
}); |
|
} catch (err) { |
|
console.error('health error:', err); |
|
res.status(500).json({ ok: false, error: String(err) }); |
|
} |
|
}); |
|
|
|
app.get('/api/config/public', (_req, res) => { |
|
res.json({ |
|
ok: true, |
|
chain: CHAIN, |
|
rpc: RPC_URL, |
|
requiredConfirmations: toInt(REQUIRED_CONFIRMATIONS, 3), |
|
payTo: PAY_TO, |
|
gateway: PUBLIC_GATEWAY, |
|
tierBytes: 20 * 1024 * 1024, |
|
priceEgazPerTier: 3 |
|
}); |
|
}); |
|
|
|
/* ---------- RATES (CoinGecko + fallback to env ratio) ---------- */ |
|
app.get('/api/rates', async (_req, res) => { |
|
const egazPerEti = Number(EGAZ_PER_ETI) || 25; |
|
const etiPerEgaz = 1 / egazPerEti; |
|
|
|
let egazUsd = null; |
|
let etiUsd = null; |
|
let source = 'env'; |
|
|
|
async function fetchJson(url, timeoutMs = 4000) { |
|
const ac = new AbortController(); |
|
const t = setTimeout(() => ac.abort(), timeoutMs); |
|
try { |
|
const r = await fetch(url, { headers: { accept: 'application/json' }, signal: ac.signal }); |
|
if (!r.ok) throw new Error(`HTTP ${r.status}`); |
|
return await r.json(); |
|
} finally { |
|
clearTimeout(t); |
|
} |
|
} |
|
|
|
try { |
|
// Try CoinGecko simple price for both tokens. |
|
// IDs used: 'egaz' and 'etica' (adjust here if CoinGecko changes slugs) |
|
const cg = await fetchJson('https://api.coingecko.com/api/v3/simple/price?ids=egaz,etica&vs_currencies=usd'); |
|
if (cg?.egaz?.usd != null) egazUsd = Number(cg.egaz.usd); |
|
if (cg?.etica?.usd != null) etiUsd = Number(cg.etica.usd); |
|
|
|
if (egazUsd != null && etiUsd != null) { |
|
source = 'coingecko'; |
|
} else if (egazUsd != null) { |
|
etiUsd = Number((egazUsd * egazPerEti).toFixed(6)); |
|
source = 'coingecko+env'; |
|
} else if (etiUsd != null) { |
|
egazUsd = Number((etiUsd / egazPerEti).toFixed(6)); |
|
source = 'coingecko+env'; |
|
} |
|
} catch (e) { |
|
// ignore, fall back to env-only |
|
} |
|
|
|
res.set('Cache-Control', 'no-store'); |
|
res.json({ ok: true, source, egazUsd, etiUsd, egazPerEti, etiPerEgaz }); |
|
}); |
|
|
|
/* ---------- Files + Recent ---------- */ |
|
app.get('/api/files', async (req, res) => { |
|
const address = (req.query.address || '').toString(); |
|
const vpathPrefix = (req.query.vpathPrefix || '').toString(); |
|
if (!address) return res.json({ ok: true, address: null, files: [] }); |
|
try { |
|
let q = ` |
|
SELECT |
|
quote_id AS "quoteId", |
|
filename AS "filename", |
|
COALESCE(size_bytes,0) AS "sizeBytes", |
|
address AS "address", |
|
currency AS "currency", |
|
tx_hash AS "txHash", |
|
status AS "status", |
|
created_at AS "createdAt", |
|
expires_at AS "expiresAt", |
|
cid AS "cid", |
|
path AS "path", |
|
label AS "label", |
|
vpath AS "vpath", |
|
NULL::text AS "gateway" |
|
FROM payments |
|
WHERE lower(address) = lower($1) |
|
`; |
|
const params = [address]; |
|
if (vpathPrefix) { q += ` AND vpath ILIKE $2 || '%'`; params.push(vpathPrefix); } |
|
q += ` ORDER BY created_at DESC LIMIT 500`; |
|
const { rows } = await sql(q, params); |
|
res.json({ ok: true, address: address.toLowerCase(), files: rows }); |
|
} catch (err) { |
|
console.error('files error', err); |
|
res.status(500).json({ ok: false, error: String(err) }); |
|
} |
|
}); |
|
|
|
app.get('/api/recent', async (req, res) => { |
|
try { |
|
const limit = Math.min(50, parseInt(req.query.limit || '5', 10) || 5); |
|
const { rows } = await sql(` |
|
SELECT |
|
created_at AS "createdAt", |
|
filename AS "filename", |
|
COALESCE(size_bytes,0) AS "sizeBytes", |
|
address AS "address", |
|
currency AS "currency", |
|
status AS "status", |
|
cid AS "cid", |
|
vpath AS "vpath", |
|
path AS "path", |
|
label AS "label" |
|
FROM payments |
|
WHERE cid IS NOT NULL |
|
ORDER BY created_at DESC |
|
LIMIT $1 |
|
`, [limit]); |
|
|
|
res.set('Cache-Control', 'no-store'); |
|
res.json({ ok: true, items: rows }); |
|
} catch (e) { |
|
console.error('recent error:', e); |
|
res.status(500).json({ ok: false, error: String(e) }); |
|
} |
|
}); |
|
|
|
/* ---------- Quotes/Payments/Upload (unchanged) ---------- */ |
|
app.post('/api/quote', async (req, res) => { |
|
try { |
|
const { address = null, sizeBytes = 0, currency = 'EGAZ', chain = CHAIN } = req.body || {}; |
|
const size = Number(sizeBytes) || 0; |
|
|
|
const egazPerEti = Number(EGAZ_PER_ETI) || 25; |
|
const tierBytes = 20 * 1024 * 1024; |
|
const tiers = Math.max(1, Math.ceil(size / tierBytes)); |
|
const priceEgaz = Math.max(3, tiers * 3); |
|
const priceEti = (priceEgaz / egazPerEti).toFixed(2); |
|
|
|
const quoteId = Number(BigInt(Date.now()) * 1000n + BigInt(Math.floor(Math.random() * 1000))); |
|
|
|
await sql( |
|
`INSERT INTO upload_quotes ( |
|
quote_id, address, filename, size_bytes, chain, currency, tiers, tier_mb, price_egaz, |
|
required_confirmations, pay_to |
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) |
|
ON CONFLICT (quote_id) DO NOTHING |
|
RETURNING quote_id`, |
|
[ |
|
quoteId, |
|
address, |
|
req.body.filename || req.body.fileName || 'unknown', |
|
req.body.sizeBytes ?? req.body.size_bytes ?? 0, |
|
chain, |
|
currency, |
|
tiers, |
|
20, |
|
priceEgaz, |
|
toInt(REQUIRED_CONFIRMATIONS, 3), |
|
PAY_TO |
|
] |
|
); |
|
|
|
res.json({ |
|
ok: true, |
|
quoteId, |
|
size, |
|
tiers, |
|
tierBytes, |
|
priceEgaz: String(priceEgaz), |
|
priceEti: String(priceEti), |
|
currency, |
|
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), |
|
payTo: PAY_TO, |
|
}); |
|
} catch (err) { |
|
console.error('quote error:', err); |
|
res.status(500).json({ ok: false, error: String(err) }); |
|
} |
|
}); |
|
|
|
app.post('/api/payments/record', async (req, res) => { |
|
try { |
|
const { |
|
quoteId, |
|
address, |
|
currency = 'EGAZ', |
|
txHash = null, |
|
filename = null, |
|
sizeBytes = null, |
|
cid = null, |
|
path = null, |
|
label = null, |
|
vpath = (req.body.virtualPath || req.body.pathInside || req.body.path || null) |
|
} = req.body || {}; |
|
|
|
if (!quoteId || !address) { |
|
return res.status(400).json({ ok: false, error: 'quoteId and address required' }); |
|
} |
|
|
|
const q = await sql(`SELECT 1 FROM upload_quotes WHERE quote_id=$1`, [quoteId]); |
|
if (q.rowCount === 0) { |
|
return res.status(400).json({ ok: false, error: 'unknown quoteId' }); |
|
} |
|
|
|
await sql( |
|
`INSERT INTO payments (quote_id, address, currency, tx_hash, status, filename, size_bytes, cid, path, label, vpath, created_at) |
|
VALUES ($1, lower($2), $3, $4, 'pending', $5, $6, $7, $8, $9, $10, now()) |
|
ON CONFLICT (quote_id) DO UPDATE SET |
|
address=excluded.address, |
|
currency=excluded.currency, |
|
tx_hash=excluded.tx_hash, |
|
filename=excluded.filename, |
|
size_bytes=excluded.size_bytes, |
|
cid=excluded.cid, |
|
path=excluded.path, |
|
label=excluded.label, |
|
vpath=excluded.vpath |
|
`, |
|
[quoteId, address, currency, txHash, filename, sizeBytes, cid, path, label, vpath] |
|
); |
|
|
|
res.json({ ok: true }); |
|
} catch (err) { |
|
console.error('POST /api/payments/record error', err); |
|
res.status(500).json({ ok: false, error: String(err) }); |
|
} |
|
}); |
|
|
|
async function refreshConfirmations(quoteId) { |
|
const r0 = await sql(`SELECT quote_id, tx_hash, status, confs, required_confs, mined_block FROM payments WHERE quote_id=$1`, [quoteId]); |
|
if (r0.rowCount === 0) return null; |
|
const row = r0.rows[0]; |
|
if (!row.tx_hash) return row; |
|
|
|
try { |
|
const provider = new ethers.JsonRpcProvider(RPC_URL); |
|
const latest = await provider.getBlockNumber(); |
|
let mined = row.mined_block; |
|
let confs = 0; |
|
|
|
const receipt = await provider.getTransactionReceipt(row.tx_hash); |
|
if (receipt && receipt.blockNumber != null) mined = receipt.blockNumber; |
|
if (mined == null) { |
|
const tx = await provider.getTransaction(row.tx_hash); |
|
if (tx && tx.blockNumber != null) mined = tx.blockNumber; |
|
} |
|
|
|
if (mined != null) confs = Math.max(0, latest - Number(mined) + 1); |
|
const required = row.required_confs ?? toInt(REQUIRED_CONFIRMATIONS, 3); |
|
const newStatus = confs >= required ? (row.status === 'paid' ? 'paid' : 'confirmed') : row.status; |
|
|
|
await sql( |
|
`UPDATE payments SET confs=$2, latest_block=$3, mined_block=$4, status=$5 WHERE quote_id=$1`, |
|
[quoteId, confs, latest, mined, newStatus] |
|
); |
|
|
|
return { ...row, confs, latest_block: latest, mined_block: mined, status: newStatus }; |
|
} catch (e) { |
|
console.error('refreshConfirmations error:', e); |
|
return row; |
|
} |
|
} |
|
|
|
app.get('/api/payments/status', async (req, res) => { |
|
try { |
|
const quoteId = toInt(req.query.quoteId); |
|
if (!quoteId) return res.status(400).json({ ok: false, error: 'quoteId required' }); |
|
|
|
const r = await refreshConfirmations(quoteId) || {}; |
|
const r2 = await sql(`SELECT * FROM payments WHERE quote_id=$1`, [quoteId]); |
|
if (r2.rowCount === 0) return res.status(404).json({ ok: false, error: 'not found' }); |
|
const p = r2.rows[0]; |
|
|
|
res.json({ |
|
ok: true, |
|
quoteId: String(quoteId), |
|
status: p.status, |
|
txHash: p.tx_hash, |
|
confirmations: p.confs ?? 0, |
|
requiredConfirmations: p.required_confs ?? toInt(REQUIRED_CONFIRMATIONS, 3), |
|
cid: p.cid, |
|
path: p.path, |
|
expiresAt: p.expires_at, |
|
payTo: PAY_TO |
|
}); |
|
} catch (err) { |
|
console.error('status error:', err); |
|
res.status(500).json({ ok: false, error: String(err) }); |
|
} |
|
}); |
|
|
|
app.post('/api/payments/confirm', async (_req, res) => { res.json({ ok: true }); }); |
|
|
|
app.post('/api/upload', async (req, res) => { |
|
try { |
|
const { quoteId, filename, contentBase64, vpath = null, label = null } = req.body || {}; |
|
if (!quoteId || !filename || !contentBase64) { |
|
return res.status(400).json({ ok: false, error: 'quoteId, filename, contentBase64 required' }); |
|
} |
|
|
|
const pr = await sql(`SELECT status FROM payments WHERE quote_id=$1`, [quoteId]); |
|
if (pr.rowCount === 0) return res.status(404).json({ ok: false, error: 'payment not found' }); |
|
const status = pr.rows[0].status; |
|
if (!(status === 'confirmed' || status === 'paid')) { |
|
return res.status(400).json({ ok: false, error: 'payment not confirmed' }); |
|
} |
|
|
|
const payload = Buffer.from(contentBase64, 'base64'); |
|
const add = await fetch(`${IPFS_API}/add?pin=true&cid-version=1&wrap-with-directory=false`, { |
|
method: 'POST', |
|
body: (() => { |
|
const form = new FormData(); |
|
form.append('file', new Blob([payload]), filename); |
|
return form; |
|
})() |
|
}); |
|
const addJson = await add.json(); |
|
const cid = addJson.Hash; |
|
const ipfsPath = `/ipfs/${cid}`; |
|
|
|
await sql( |
|
`UPDATE payments |
|
SET cid=$2, path=$3, status='paid', label=COALESCE($4,label), vpath=COALESCE($5,vpath), |
|
expires_at = now() + interval '6 months' |
|
WHERE quote_id=$1`, |
|
[quoteId, cid, ipfsPath, label, vpath] |
|
); |
|
|
|
res.json({ ok: true, cid, path: ipfsPath }); |
|
} catch (err) { |
|
console.error('upload error:', err); |
|
res.status(500).json({ ok: false, error: String(err) }); |
|
} |
|
}); |
|
|
|
app.use((err, _req, res, _next) => { console.error('unhandled error:', err); res.status(500).json({ ok: false, error: String(err) }); }); |
|
|
|
(async () => { |
|
try { |
|
await migrate(); |
|
app.listen(parseInt(PORT, 10), HOST, () => { |
|
console.log(`ipfs-web backend listening on ${HOST}:${PORT} (chain=${CHAIN})`); |
|
}); |
|
} catch (err) { console.error('startup/migrate error', err); process.exit(1); } |
|
})();
|
|
|