diff --git a/server/status.js b/server/status.js index 4aa9797..7199fdb 100644 --- a/server/status.js +++ b/server/status.js @@ -22,7 +22,138 @@ const { const port = 8080 const fulcrumTimeoutMs = 3000 -function queryFulcrum(fulcrumUrl, method, params = []) { +const fs = require('fs') +const path = require('path') + +const addressCacheDir = '/data/cache/addresses' +const addressCacheMaxAgeMs = 60000 +const addressRefreshes = new Map() + +function ensureAddressCacheDir() { + fs.mkdirSync(addressCacheDir, { + recursive: true, + }) +} + +function getAddressCachePath(scripthash) { + return path.join( + addressCacheDir, + `${scripthash}.json`, + ) +} + +function readAddressCache(scripthash) { + try { + const cachePath = + getAddressCachePath(scripthash) + + const stat = fs.statSync(cachePath) + + const data = + JSON.parse( + fs.readFileSync( + cachePath, + 'utf8', + ), + ) + + if ( + !data || + typeof data !== 'object' || + !data.address + ) { + return null + } + + return { + data, + ageMs: Math.max( + 0, + Date.now() - stat.mtimeMs, + ), + } + } catch { + return null + } +} + +function writeAddressCache(data) { + try { + ensureAddressCacheDir() + + const cachePath = + getAddressCachePath( + data.scripthash, + ) + + const tempPath = + `${cachePath}.${process.pid}.tmp` + + fs.writeFileSync( + tempPath, + JSON.stringify(data), + 'utf8', + ) + + fs.renameSync( + tempPath, + cachePath, + ) + } catch (error) { + console.error( + 'Failed to write address cache:', + error, + ) + } +} + +function refreshAddressCache( + address, + scripthash, +) { + const existing = + addressRefreshes.get(scripthash) + + if (existing) { + return existing + } + + const refresh = + getAddressDataFromFulcrum( + address, + scripthash, + ) + .then((data) => { + writeAddressCache(data) + return data + }) + .catch((error) => { + console.error( + `Failed to refresh address ${address}:`, + error, + ) + + throw error + }) + .finally(() => { + addressRefreshes.delete( + scripthash, + ) + }) + + addressRefreshes.set( + scripthash, + refresh, + ) + + return refresh +} + +function queryFulcrum( + fulcrumUrl, + method, + params = [], +) { return new Promise((resolve) => { if (!fulcrumUrl) { resolve({ @@ -98,7 +229,9 @@ function queryFulcrum(fulcrumUrl, method, params = []) { params, }) + '\n' - console.log(`Sending Fulcrum request: ${request.trim()}`) + console.log( + `Sending Fulcrum request: ${request.trim()}`, + ) socket.write(request) }) @@ -106,7 +239,9 @@ function queryFulcrum(fulcrumUrl, method, params = []) { socket.on('data', (data) => { const chunk = data.toString() - console.log(`Fulcrum response data: ${chunk.trim()}`) + console.log( + `Fulcrum response data: ${chunk.trim()}`, + ) response += chunk @@ -189,14 +324,17 @@ function queryFulcrum(fulcrumUrl, method, params = []) { finish({ ok: false, result: null, - error: 'Connection closed before a response was received', + error: + 'Connection closed before a response was received', }) } }) }) } -async function getFulcrumStatus(fulcrumUrl) { +async function getFulcrumStatus( + fulcrumUrl, +) { const version = await queryFulcrum( fulcrumUrl, 'server.version', @@ -229,13 +367,20 @@ async function getFulcrumStatus(fulcrumUrl) { headers.ok && headers.result ? headers.result.height ?? null : null, - error: headers.ok ? null : headers.error, + error: headers.ok + ? null + : headers.error, } } async function getStatus() { - const fulcrumUrl = process.env.MUNIN_FULCRUM_URL || null - const fulcrum = await getFulcrumStatus(fulcrumUrl) + const fulcrumUrl = + process.env.MUNIN_FULCRUM_URL || null + + const fulcrum = + await getFulcrumStatus( + fulcrumUrl, + ) return { service: 'running', @@ -247,23 +392,17 @@ async function getStatus() { } } -async function getAddressData(address) { - let scripthash - - try { - scripthash = addressToScripthash(address) - } catch (error) { - throw new Error( - error instanceof Error - ? error.message - : 'Invalid Bitcoin address', - ) - } - - const fulcrumUrl = process.env.MUNIN_FULCRUM_URL || null +async function getAddressDataFromFulcrum( + address, + scripthash, +) { + const fulcrumUrl = + process.env.MUNIN_FULCRUM_URL || null if (!fulcrumUrl) { - throw new Error('Fulcrum URL is not configured') + throw new Error( + 'Fulcrum URL is not configured', + ) } const balance = await queryFulcrum( @@ -306,42 +445,131 @@ async function getAddressData(address) { address, scripthash, confirmedBalance: - Number(balance.result?.confirmed || 0), + Number( + balance.result?.confirmed || 0, + ), unconfirmedBalance: - Number(balance.result?.unconfirmed || 0), - utxos: Array.isArray(utxos.result) - ? utxos.result - : [], - history: Array.isArray(history.result) - ? history.result - : [], + Number( + balance.result?.unconfirmed || 0, + ), + utxos: + Array.isArray( + utxos.result, + ) + ? utxos.result + : [], + history: + Array.isArray( + history.result, + ) + ? history.result + : [], + lastSynced: + new Date().toISOString(), + } +} + +async function getAddressData( + address, +) { + let scripthash + + try { + scripthash = + addressToScripthash( + address, + ) + } catch (error) { + throw new Error( + error instanceof Error + ? error.message + : 'Invalid Bitcoin address', + ) + } + + const cached = + readAddressCache( + scripthash, + ) + + if (cached) { + if ( + cached.ageMs > + addressCacheMaxAgeMs + ) { + refreshAddressCache( + address, + scripthash, + ).catch(() => {}) + } + + return { + ...cached.data, + cacheAgeMs: + cached.ageMs, + } + } + + const data = + await refreshAddressCache( + address, + scripthash, + ) + + return { + ...data, + cacheAgeMs: 0, } } async function getAddressesWithData() { - const addresses = await listAddresses() + const addresses = + await listAddresses() + const results = [] - for (const entry of addresses) { + for ( + const entry of addresses + ) { try { - const data = await getAddressData(entry.address) + const data = + await getAddressData( + entry.address, + ) results.push({ ...entry, - scripthash: data.scripthash, - confirmedBalance: data.confirmedBalance, - unconfirmedBalance: data.unconfirmedBalance, - utxos: data.utxos, - history: data.history, + scripthash: + data.scripthash, + confirmedBalance: + data.confirmedBalance, + unconfirmedBalance: + data.unconfirmedBalance, + utxos: + data.utxos, + history: + data.history, + lastSynced: + data.lastSynced || + null, + cacheAgeMs: + Number( + data.cacheAgeMs || + 0, + ), error: null, }) } catch (error) { results.push({ ...entry, - confirmedBalance: null, - unconfirmedBalance: null, + confirmedBalance: + null, + unconfirmedBalance: + null, utxos: [], history: [], + lastSynced: null, + cacheAgeMs: null, error: error instanceof Error ? error.message @@ -353,373 +581,543 @@ async function getAddressesWithData() { return results } -const server = http.createServer(async (req, res) => { - if (req.method === 'POST' && req.url === '/api/login') { - let body = '' +const server = + http.createServer( + async (req, res) => { + if ( + req.method === 'POST' && + req.url === '/api/login' + ) { + let body = '' - req.on('data', (chunk) => { - body += chunk - }) + req.on('data', (chunk) => { + body += chunk + }) - req.on('end', () => { - try { - const input = JSON.parse(body) + req.on('end', () => { + try { + const input = + JSON.parse(body) - if (!checkPassword(input.password)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) + if ( + !checkPassword( + input.password, + ) + ) { + res.writeHead( + 401, + { + 'Content-Type': + 'application/json', + }, + ) + + res.end( + JSON.stringify({ + error: + 'Invalid password', + }), + ) + + return + } + + const token = + createSession() + + res.writeHead( + 200, + { + 'Content-Type': + 'application/json', + 'Set-Cookie': + 'munin_session=' + + encodeURIComponent( + token, + ) + + '; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400', + }, + ) + + res.end( + JSON.stringify({ + ok: true, + }), + ) + } catch { + res.writeHead( + 400, + { + 'Content-Type': + 'application/json', + }, + ) + + res.end( + JSON.stringify({ + error: + 'Invalid request', + }), + ) + } + }) + + return + } + + if ( + req.url === + '/api/status' + ) { + if ( + !isValidSession(req) + ) { + res.writeHead( + 401, + { + 'Content-Type': + 'application/json', + }, + ) res.end( JSON.stringify({ - error: 'Invalid password', + error: + 'Authentication required', }), ) return } - const token = createSession() + try { + const status = + await getStatus() - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Set-Cookie': - 'munin_session=' + - encodeURIComponent(token) + - '; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400', - }) + res.writeHead( + 200, + { + 'Content-Type': + 'application/json', + }, + ) - res.end( - JSON.stringify({ - ok: true, - }), - ) - } catch { - res.writeHead(400, { - 'Content-Type': 'application/json', - }) + res.end( + JSON.stringify( + status, + ), + ) + } catch (error) { + console.error( + 'Failed to get service status:', + error, + ) - res.end( - JSON.stringify({ - error: 'Invalid request', - }), - ) + res.writeHead( + 500, + { + 'Content-Type': + 'application/json', + }, + ) + + res.end( + JSON.stringify({ + service: + 'running', + error: + 'Failed to determine service status', + }), + ) + } + + return } - }) - return - } + if ( + req.method === 'GET' && + req.url === + '/api/addresses' + ) { + if ( + !isValidSession(req) + ) { + res.writeHead( + 401, + { + 'Content-Type': + 'application/json', + }, + ) - if (req.url === '/api/status') { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) + res.end( + JSON.stringify({ + error: + 'Authentication required', + }), + ) - res.end( - JSON.stringify({ - error: 'Authentication required', - }), - ) + return + } - return - } + try { + const addresses = + await listAddresses() - try { - const status = await getStatus() + res.writeHead( + 200, + { + 'Content-Type': + 'application/json', + }, + ) - res.writeHead(200, { - 'Content-Type': 'application/json', - }) + res.end( + JSON.stringify({ + addresses, + }), + ) + } catch (error) { + console.error( + 'Failed to list addresses:', + error, + ) - res.end(JSON.stringify(status)) - } catch (error) { - console.error( - 'Failed to get service status:', - error, - ) + res.writeHead( + 500, + { + 'Content-Type': + 'application/json', + }, + ) - res.writeHead(500, { - 'Content-Type': 'application/json', - }) + res.end( + JSON.stringify({ + error: + 'Failed to list addresses', + }), + ) + } - res.end( - JSON.stringify({ - service: 'running', - error: 'Failed to determine service status', - }), - ) - } - - return - } - - if (req.method === 'GET' && req.url === '/api/addresses') { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - error: 'Authentication required', - }), - ) - - return - } - - try { - const addresses = await listAddresses() - - res.writeHead(200, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - addresses, - }), - ) - } catch (error) { - console.error( - 'Failed to list addresses:', - error, - ) - - res.writeHead(500, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - error: 'Failed to list addresses', - }), - ) - } - - return - } - - if ( - req.method === 'GET' && - req.url === '/api/addresses/data' - ) { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - error: 'Authentication required', - }), - ) - - return - } - - try { - const addresses = await getAddressesWithData() - - res.writeHead(200, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - addresses, - }), - ) - } catch (error) { - console.error( - 'Failed to get address data:', - error, - ) - - res.writeHead(500, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - error: 'Failed to get address data', - }), - ) - } - - return - } - - if ( - req.method === 'POST' && - req.url === '/api/addresses' - ) { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - error: 'Authentication required', - }), - ) - - return - } - - let body = '' - - req.on('data', (chunk) => { - body += chunk - }) - - req.on('end', async () => { - try { - const input = JSON.parse(body) - - const entry = await addAddress( - input.address, - input.label, - ) - - res.writeHead(201, { - 'Content-Type': 'application/json', - }) - - res.end(JSON.stringify(entry)) - } catch (error) { - res.writeHead(400, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - error: - error instanceof Error - ? error.message - : 'Failed to add address', - }), - ) + return } - }) - return - } + if ( + req.method === 'GET' && + req.url === + '/api/addresses/data' + ) { + if ( + !isValidSession(req) + ) { + res.writeHead( + 401, + { + 'Content-Type': + 'application/json', + }, + ) - if ( - req.method === 'DELETE' && - req.url === '/api/addresses' - ) { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) + res.end( + JSON.stringify({ + error: + 'Authentication required', + }), + ) - res.end( - JSON.stringify({ - error: 'Authentication required', - }), - ) + return + } - return - } + try { + const addresses = + await getAddressesWithData() - let body = '' + res.writeHead( + 200, + { + 'Content-Type': + 'application/json', + }, + ) - req.on('data', (chunk) => { - body += chunk - }) + res.end( + JSON.stringify({ + addresses, + }), + ) + } catch (error) { + console.error( + 'Failed to get address data:', + error, + ) - req.on('end', async () => { - try { - const input = JSON.parse(body) + res.writeHead( + 500, + { + 'Content-Type': + 'application/json', + }, + ) - await removeAddress(input.address) + res.end( + JSON.stringify({ + error: + 'Failed to get address data', + }), + ) + } - res.writeHead(200, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - ok: true, - }), - ) - } catch (error) { - res.writeHead(400, { - 'Content-Type': 'application/json', - }) - - res.end( - JSON.stringify({ - error: - error instanceof Error - ? error.message - : 'Failed to remove address', - }), - ) + return } - }) - return - } + if ( + req.method === 'POST' && + req.url === + '/api/addresses' + ) { + if ( + !isValidSession(req) + ) { + res.writeHead( + 401, + { + 'Content-Type': + 'application/json', + }, + ) - if ( - req.method === 'GET' && - req.url.startsWith('/api/transactions/') - ) { - if (!isValidSession(req)) { - res.writeHead(401, { - 'Content-Type': 'application/json', - }) + res.end( + JSON.stringify({ + error: + 'Authentication required', + }), + ) - res.end( - JSON.stringify({ - error: 'Authentication required', - }), - ) + return + } - return - } + let body = '' - const txid = decodeURIComponent( - req.url.slice('/api/transactions/'.length), - ) + req.on('data', (chunk) => { + body += chunk + }) - try { - const transaction = await getTransaction(txid) + req.on( + 'end', + async () => { + try { + const input = + JSON.parse(body) - res.writeHead(200, { - 'Content-Type': 'application/json', - }) + const entry = + await addAddress( + input.address, + input.label, + ) - res.end(JSON.stringify(transaction)) - } catch (error) { - res.writeHead(400, { - 'Content-Type': 'application/json', - }) + res.writeHead( + 201, + { + 'Content-Type': + 'application/json', + }, + ) - res.end( - JSON.stringify({ - error: - error instanceof Error - ? error.message - : 'Failed to get transaction', - }), - ) - } + res.end( + JSON.stringify( + entry, + ), + ) + } catch (error) { + res.writeHead( + 400, + { + 'Content-Type': + 'application/json', + }, + ) - return - } + res.end( + JSON.stringify({ + error: + error instanceof Error + ? error.message + : 'Failed to add address', + }), + ) + } + }, + ) - res.writeHead(404) - res.end('Not found') -}) + return + } -server.listen(port, () => { - console.log( - `Status API listening on port ${port}`, + if ( + req.method === 'DELETE' && + req.url === + '/api/addresses' + ) { + if ( + !isValidSession(req) + ) { + res.writeHead( + 401, + { + 'Content-Type': + 'application/json', + }, + ) + + res.end( + JSON.stringify({ + error: + 'Authentication required', + }), + ) + + return + } + + let body = '' + + req.on('data', (chunk) => { + body += chunk + }) + + req.on( + 'end', + async () => { + try { + const input = + JSON.parse(body) + + await removeAddress( + input.address, + ) + + res.writeHead( + 200, + { + 'Content-Type': + 'application/json', + }, + ) + + res.end( + JSON.stringify({ + ok: true, + }), + ) + } catch (error) { + res.writeHead( + 400, + { + 'Content-Type': + 'application/json', + }, + ) + + res.end( + JSON.stringify({ + error: + error instanceof Error + ? error.message + : 'Failed to remove address', + }), + ) + } + }, + ) + + return + } + + if ( + req.method === 'GET' && + req.url.startsWith( + '/api/transactions/', + ) + ) { + if ( + !isValidSession(req) + ) { + res.writeHead( + 401, + { + 'Content-Type': + 'application/json', + }, + ) + + res.end( + JSON.stringify({ + error: + 'Authentication required', + }), + ) + + return + } + + const txid = + decodeURIComponent( + req.url.slice( + '/api/transactions/' + .length, + ), + ) + + try { + const transaction = + await getTransaction( + txid, + ) + + res.writeHead( + 200, + { + 'Content-Type': + 'application/json', + }, + ) + + res.end( + JSON.stringify( + transaction, + ), + ) + } catch (error) { + res.writeHead( + 400, + { + 'Content-Type': + 'application/json', + }, + ) + + res.end( + JSON.stringify({ + error: + error instanceof Error + ? error.message + : 'Failed to get transaction', + }), + ) + } + + return + } + + res.writeHead(404) + res.end('Not found') + }, ) -}) \ No newline at end of file + +server.listen( + port, + () => { + console.log( + `Status API listening on port ${port}`, + ) + }, +) \ No newline at end of file