|
|
|
|
@@ -15,6 +15,10 @@ const distDir = path.join(rootDir, 'dist')
|
|
|
|
|
const port = Number(process.env.PORT || 3001)
|
|
|
|
|
const host = process.env.HOST || '127.0.0.1'
|
|
|
|
|
const appMode = ['public', 'admin', 'all'].includes(process.env.APP_MODE) ? process.env.APP_MODE : 'all'
|
|
|
|
|
const adminAllowedHosts = String(process.env.ADMIN_ALLOWED_HOSTS || '')
|
|
|
|
|
.split(',')
|
|
|
|
|
.map((value) => value.trim().toLowerCase())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
const seedDevMembers = process.env.DEV_SEED_MEMBERS === 'true'
|
|
|
|
|
const membershipMonthlyUsd = 350
|
|
|
|
|
const bitcoinFallbackUsd = 79592.095
|
|
|
|
|
@@ -81,6 +85,35 @@ const rateBuckets = new Map()
|
|
|
|
|
const publicApiEnabled = () => appMode !== 'admin'
|
|
|
|
|
const adminApiEnabled = () => appMode !== 'public'
|
|
|
|
|
|
|
|
|
|
const normalizeHost = (value) => {
|
|
|
|
|
const hostValue = String(value || '').split(',')[0].trim().toLowerCase()
|
|
|
|
|
if (hostValue.startsWith('[')) return hostValue.slice(1, hostValue.indexOf(']') > 0 ? hostValue.indexOf(']') : undefined)
|
|
|
|
|
return hostValue.split(':')[0]
|
|
|
|
|
}
|
|
|
|
|
const normalizeIp = (value) => String(value || '').trim().replace(/^::ffff:/, '')
|
|
|
|
|
const isPrivateIp = (value) => {
|
|
|
|
|
const ip = normalizeIp(value)
|
|
|
|
|
if (!ip) return false
|
|
|
|
|
if (ip === '::1' || ip === '127.0.0.1' || ip.startsWith('127.')) return true
|
|
|
|
|
const parts = ip.split('.').map((part) => Number.parseInt(part, 10))
|
|
|
|
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false
|
|
|
|
|
if (parts[0] === 10) return true
|
|
|
|
|
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true
|
|
|
|
|
return parts[0] === 192 && parts[1] === 168
|
|
|
|
|
}
|
|
|
|
|
const isLocalHost = (value) => {
|
|
|
|
|
const hostname = normalizeHost(value)
|
|
|
|
|
return hostname === 'localhost' || hostname.endsWith('.local') || isPrivateIp(hostname) || adminAllowedHosts.includes(hostname)
|
|
|
|
|
}
|
|
|
|
|
const privilegedConnectionAllowed = (req) => {
|
|
|
|
|
const requestHost = normalizeHost(req.headers['x-forwarded-host'] || req.headers.host)
|
|
|
|
|
if (!isLocalHost(requestHost)) return false
|
|
|
|
|
const socketAddress = normalizeIp(req.socket.remoteAddress || '')
|
|
|
|
|
if (!isPrivateIp(socketAddress)) return false
|
|
|
|
|
const forwardedFor = String(req.headers['x-forwarded-for'] || '').split(',').map((value) => normalizeIp(value)).filter(Boolean)
|
|
|
|
|
return forwardedFor.length ? forwardedFor.every(isPrivateIp) : true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey) {
|
|
|
|
|
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
|
|
|
|
|
}
|
|
|
|
|
@@ -93,6 +126,36 @@ const json = (res, status, body) => {
|
|
|
|
|
res.end(JSON.stringify(body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const adminConnectionUnavailable = (res) => {
|
|
|
|
|
res.writeHead(200, {
|
|
|
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
|
|
|
'Cache-Control': 'no-store',
|
|
|
|
|
})
|
|
|
|
|
res.end(`<!doctype html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<title>Admin Connection Required</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #080808; color: #fafafa; }
|
|
|
|
|
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, rgba(242,169,0,0.12), transparent 34%), #080808; }
|
|
|
|
|
main { width: min(100%, 460px); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 28px; background: rgba(255,255,255,0.045); }
|
|
|
|
|
p { margin: 0 0 8px; color: rgba(255,255,255,0.62); line-height: 1.5; }
|
|
|
|
|
h1 { margin: 0 0 12px; font-size: clamp(28px, 8vw, 44px); line-height: 0.95; text-transform: uppercase; }
|
|
|
|
|
strong { color: #f2a900; font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; }
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<main>
|
|
|
|
|
<strong>L484 Admin</strong>
|
|
|
|
|
<h1>Only Available to admin connection</h1>
|
|
|
|
|
<p>Use the local admin connection to manage members, payments, cards, and site settings.</p>
|
|
|
|
|
</main>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rateLimit = (req, res) => {
|
|
|
|
|
const method = String(req.method || 'GET').toUpperCase()
|
|
|
|
|
if (!req.url?.startsWith('/api/') || method === 'GET') return false
|
|
|
|
|
@@ -398,6 +461,10 @@ const requireAdmin = (req, res) => {
|
|
|
|
|
json(res, 404, { error: 'Admin API is disabled on this deployment.' })
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if (!privilegedConnectionAllowed(req)) {
|
|
|
|
|
json(res, 404, { error: 'Admin API is only available from the local admin connection.' })
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if (!isAdminPubkey(getAuthPubkey(req))) {
|
|
|
|
|
json(res, 403, { error: 'This npub is not an admin. Please request access and we will authorise it if you are permissioned.' })
|
|
|
|
|
return false
|
|
|
|
|
@@ -607,6 +674,10 @@ const hasControllerAuth = (req) => {
|
|
|
|
|
|
|
|
|
|
const serveStatic = (req, res) => {
|
|
|
|
|
const requested = decodeURIComponent(new URL(req.url, `http://${req.headers.host}`).pathname)
|
|
|
|
|
if (['/admin', '/edit'].includes(requested) && !privilegedConnectionAllowed(req)) {
|
|
|
|
|
adminConnectionUnavailable(res)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const filePath = requested === '/' ? path.join(distDir, 'index.html') : path.join(distDir, requested)
|
|
|
|
|
const safePath = filePath.startsWith(distDir) && existsSync(filePath) ? filePath : path.join(distDir, 'index.html')
|
|
|
|
|
const ext = path.extname(safePath)
|
|
|
|
|
@@ -1082,8 +1153,8 @@ const handleApi = async (req, res) => {
|
|
|
|
|
return json(res, 200, {
|
|
|
|
|
mode: appMode,
|
|
|
|
|
publicMembershipEnabled: publicApiEnabled(),
|
|
|
|
|
adminEnabled: adminApiEnabled(),
|
|
|
|
|
accessEnabled: adminApiEnabled(),
|
|
|
|
|
adminEnabled: adminApiEnabled() && privilegedConnectionAllowed(req),
|
|
|
|
|
accessEnabled: adminApiEnabled() && privilegedConnectionAllowed(req),
|
|
|
|
|
btcpayEnabled: btcpayConfigured(),
|
|
|
|
|
masterAdminConfigured: Boolean(masterAdminPubkey),
|
|
|
|
|
})
|
|
|
|
|
@@ -1145,6 +1216,7 @@ const handleApi = async (req, res) => {
|
|
|
|
|
|
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/admin/request-access') {
|
|
|
|
|
if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' })
|
|
|
|
|
if (!privilegedConnectionAllowed(req)) return json(res, 404, { error: 'Admin API is only available from the local admin connection.' })
|
|
|
|
|
const body = await readBody(req)
|
|
|
|
|
const pubkey = cleanText(body.pubkey, 64).toLowerCase()
|
|
|
|
|
const npub = cleanText(body.npub, 90)
|
|
|
|
|
@@ -1306,6 +1378,7 @@ const handleApi = async (req, res) => {
|
|
|
|
|
|
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/admin/events') {
|
|
|
|
|
if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' })
|
|
|
|
|
if (!privilegedConnectionAllowed(req)) return json(res, 404, { error: 'Admin API is only available from the local admin connection.' })
|
|
|
|
|
const pubkey = cleanText(url.searchParams.get('pubkey'), 80).toLowerCase()
|
|
|
|
|
if (!isAdminPubkey(pubkey)) return json(res, 403, { error: 'This npub is not an admin. Please request access and we will authorise it if you are permissioned.' })
|
|
|
|
|
res.writeHead(200, {
|
|
|
|
|
@@ -1460,6 +1533,7 @@ const handleApi = async (req, res) => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.method === 'POST' && url.pathname === '/api/access/check') {
|
|
|
|
|
if (!privilegedConnectionAllowed(req)) return json(res, 404, { error: 'Controller access is only available from the local connection.' })
|
|
|
|
|
const controllerAuthed = hasControllerAuth(req)
|
|
|
|
|
const isAdmin = (req.headers.authorization || '').startsWith('Bearer ')
|
|
|
|
|
if (!controllerAuthed && (!isAdmin || !requireAdmin(req, res))) return
|
|
|
|
|
|